Sale price, /products/7
$1,999.00Should be: $19.99, saved as 1999 cents
Cents vs dollars
cents-vs-dollars
A price saved in cents is shown as if it were dollars, so it’s 100 times too big (or the other way round).
Samethru compares what’s saved in your database with what your app actually shows on screen. When they don’t match, it shows you where it went wrong and which line of code to look at.
npx samethru initIt works inside the AI coding tool you already use: Claude Code, Cursor, Codex or Gemini CLI. Free, MIT license.
Example: what Samethru shows you when a price on a page is wrong.
Lighting
Walnut Desk Lamp
Wrong sale price
Cents shown as dollars: 100 times too big.
Look at public/assets/pages/
Every value on your screen makes a trip. It’s saved in your database, sent by your server (the API), and shown on the page. Samethru reads it at all three stops and compares them. Here is the sale price from the example above.
Saved in the database
main: SELECT sale_price_cents FROM products WHERE id = ? with [7] → sale_price_cents
1999cents, so $19.99
Sent by the API
GET /api/products/7 → $.sale_price_cents (200, as the page received it)
1999cents, so $19.99
It goes wrong here, between sent and shown, so the bug is in the code that draws the page.
Shown on the page
/products/7 → [data-testid=sale-price] (1 matched)
$1,999.00100 times too big
The price is saved in cents (1999), but the page treats that number as dollars, so $19.99 becomes $1,999.00. Samethru names the kind of bug, shows the value at each stop, and points to the line of code that shows it:
public/assets/pages/product.js:1614 product.sale_price_cents === null
15 ? ''
16 : `<dt>Sale price</dt><dd data-testid="sale-price">${formatMoney(product.sale_price_cents)}</dd>`
A real Samethru report, exactly as it printed, shown the way your AI coding tool shows it. It ran 20 checks across 12 pages of a shop app: 12 found a wrong value, and 8 confirmed the value was right. The parts left out are marked.
Once the checks are saved, you can run them again any time with one command and no AI, for example every time you push code.
npx samethru run
Finished in 14.6 s with exit code 1
Run 2026-10-04T06-36-54-175Z-c0f5 · 2026-10-04 06:37:08 UTC · http://127.0.0.1:4321
12 FAIL · 8 PASS · 0 NOT_RUN · 0 INCONCLUSIVE (20 checks)
Not every check passed.
Coverage: a check read 20 of 77 values on 12 pages (26%) and 21 of 102 API fields on 13 endpoints (21%). Values no check reads are not verified.
cart-total: Cart totalRounding error (strong): The page shows 35.79 but the API has 35.80: off by 0.01, one unit (0.01) in the last place shown. That is the typical result of adding up floating-point amounts or truncating instead of rounding.
Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).
| Layer | Value | From | Read at |
|---|---|---|---|
| Database | 3580 → 35.80 | main: SELECT SUM(ci.quantity * p.price_cents) AS total FROM cart_items ci JOIN products p ON p.id = ci.product_id → total |
2026-10-04 06:36:54 UTC |
| API | 3580 → 35.80 | GET /api/cart → $.total_cents (200, as the page received it) | 2026-10-04 06:36:54 UTC |
| Page | "$35.79" → 35.79 | /cart → [data-testid=cart-total] (1 matched) |
2026-10-04 06:36:59 UTC |
Code: page public/assets/pages/cart.js:28 · api src/routes.js:113 · db db/schema.sql:15
Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/cart-total.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-cart.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/cart-total.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/cart-total.html
Cut from the output: 176 lines: the other 11 failures, each with its evidence table
None.
None.
customer-4-orders: Dana Ruiz's orders, newest firstcustomers-list: Customer list, first page, newest firstdashboard-customer-count: Number of customers on the dashboarddashboard-last-restock: Time of the last restock on the dashboardorder-1001-total: Total of order 1001order-1002-status: Status of order 1002product-7-name: Name of product 7product-7-price: Price of the Walnut Desk LampCut from the output: 125 lines: the coverage section, every unchecked value by name
Report: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/report.md (and run.json)
Run 2026-10-04T06-36-54-175Z-c0f5 · 2026-10-04 06:37:08 UTC · http://127.0.0.1:4321
12 FAIL · 8 PASS · 0 NOT_RUN · 0 INCONCLUSIVE (20 checks)
Not every check passed.
Coverage: a check read 20 of 77 values on 12 pages (26%) and 21 of 102 API fields on 13 endpoints (21%). Values no check reads are not verified.
cart-total: Cart totalRounding error (strong): The page shows 35.79 but the API has 35.80: off by 0.01, one unit (0.01) in the last place shown. That is the typical result of adding up floating-point amounts or truncating instead of rounding.
Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).
| Layer | Value | From | Read at |
|---|---|---|---|
| Database | 3580 → 35.80 | main: SELECT SUM(ci.quantity * p.price_cents) AS total FROM cart_items ci JOIN products p ON p.id = ci.product_id → total |
2026-10-04 06:36:54 UTC |
| API | 3580 → 35.80 | GET /api/cart → $.total_cents (200, as the page received it) | 2026-10-04 06:36:54 UTC |
| Page | "$35.79" → 35.79 | /cart → [data-testid=cart-total] (1 matched) |
2026-10-04 06:36:59 UTC |
Code: page public/assets/pages/cart.js:28 · api src/routes.js:113 · db db/schema.sql:15
Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/cart-total.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-cart.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/cart-total.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/cart-total.html
dashboard-low-stock: Number of low-stock products on the dashboardStale cached value (strong): The API serves 4 but the database has 2. The response was generated at 2026-10-04 06:36:34 UTC (from Date − Age, accurate to the second) and the data changed at 2026-10-04 06:36:36.164 UTC, about 2.2 s later. Asking caches to revalidate didn't help, so a server-side cache (in memory, Redis or a reverse proxy) is serving the old value.
Where it first goes wrong: Database → API, so look in the backend (between the database and the API).
| Layer | Value | From | Read at |
|---|---|---|---|
| Database | 2 | main: SELECT COUNT(*) AS n FROM products WHERE stock < 5 AND active = 1 → n |
2026-10-04 06:37:01 UTC |
| API | 4 | GET /api/stats → $.low_stock_count (200, as the page received it) | 2026-10-04 06:37:01 UTC |
| Page | "4" → 4 | / → [data-testid=low-stock-count] (1 matched) |
2026-10-04 06:37:01 UTC |
Code: page public/assets/pages/dashboard.js:19 · api src/stats-cache.js:10 · db db/schema.sql:17
Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/dashboard-low-stock.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-home.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/dashboard-low-stock.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/dashboard-low-stock.html
lighting-result-count: Number of results in the Lighting categoryWrong count after filtering (strong): The page shows 23 for the filter category = lighting, but only 5 match. 23 is the total without the filter, so the count ignores the filter.
Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).
| Layer | Value | From | Read at |
|---|---|---|---|
| Database | 5 | main: SELECT COUNT(*) AS n FROM products p JOIN categories c ON c.id = p.category_id WHERE p.active = 1 AND c.slug = ? with ["lighting"] → n |
2026-10-04 06:37:01 UTC |
| API | 5 | GET /api/products?category=lighting → $.total (200, as the page received it) | 2026-10-04 06:37:01 UTC |
| Page | "23 results" → 23 | /products?category=lighting → [data-testid=result-count] (1 matched) |
2026-10-04 06:37:02 UTC |
Code: page public/assets/pages/products.js:32 · api src/routes.js:60 · db db/schema.sql:10
Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/lighting-result-count.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-products-category-lighting.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/lighting-result-count.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/lighting-result-count.html
order-1001-delivery-date: Delivery date of order 1001Date off by one day (strong): The page shows 2026-03-16, the day before the API's 2026-03-17. A date without a time is read as midnight UTC, and in America/New_York (UTC−4) that moment is still the previous evening.
Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).
| Layer | Value | From | Read at |
|---|---|---|---|
| Database | "2026-03-17" → 2026-03-17 | main: SELECT delivery_date FROM orders WHERE id = ? with [1001] → delivery_date |
2026-10-04 06:37:02 UTC |
| API | "2026-03-17" → 2026-03-17 | GET /api/orders/1001 → $.delivery_date (200, as the page received it) | 2026-10-04 06:37:02 UTC |
| Page | "March 16, 2026" → 2026-03-16 | /orders/1001 → [data-testid=delivery-date] (1 matched) |
2026-10-04 06:37:03 UTC |
Code: page public/assets/pages/order.js:28 · api src/routes.js:130 · db db/schema.sql:36
Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/order-1001-delivery-date.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-orders-1001.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/order-1001-delivery-date.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/order-1001-delivery-date.html
order-1001-placed-at: When order 1001 was placedTimezone shift (strong): The page's time is 4 h later than the API's (2026-03-14T22:30:00Z vs 2026-03-14T18:30:00Z). That is exactly the UTC offset of America/New_York (UTC−4) on that date, so a UTC time is being shown as if it were local time, or the reverse.
Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).
| Layer | Value | From | Read at |
|---|---|---|---|
| Database | "2026-03-14 18:30:00" → 2026-03-14T18:30:00Z | main: SELECT placed_at FROM orders WHERE id = ? with [1001] → placed_at |
2026-10-04 06:37:03 UTC |
| API | "2026-03-14T18:30:00Z" → 2026-03-14T18:30:00Z | GET /api/orders/1001 → $.placed_at (200, as the page received it) | 2026-10-04 06:37:03 UTC |
| Page | "Mar 14, 2026, 6:30 PM" → 2026-03-14T22:30:00Z | /orders/1001 → [data-testid=placed-at] (1 matched) |
2026-10-04 06:37:03 UTC |
Code: page public/assets/pages/order.js:26 · api src/routes.js:130 · db db/schema.sql:35
Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/order-1001-placed-at.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-orders-1001.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/order-1001-placed-at.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/order-1001-placed-at.html
order-1002-discount: Discount on order 1002Wrong decimal places (strong): The page shows 12.5 with 1 decimal place; it should show 2. The number is right, but it is formatted by hand (for example "$" + amount) instead of with a number formatter.
Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).
| Layer | Value | From | Read at |
|---|---|---|---|
| Database | 1250 → 12.50 | main: SELECT discount_cents FROM orders WHERE id = ? with [1002] → discount_cents |
2026-10-04 06:37:03 UTC |
| API | 1250 → 12.50 | GET /api/orders/1002 → $.discount_cents (200, as the page received it) | 2026-10-04 06:37:03 UTC |
| Page | "$12.5" → 12.5 | /orders/1002 → [data-testid=discount] (1 matched) |
2026-10-04 06:37:04 UTC |
Code: page public/assets/pages/order.js:49 · api src/routes.js:130 · db db/schema.sql:37
Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/order-1002-discount.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-orders-1002.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/order-1002-discount.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/order-1002-discount.html
order-1003-status: Status of order 1003Wrong label for a status (strong): The page shows "Cancelled" for "refunded", but "Cancelled" is the label for "cancelled". The label for "refunded" should be "Refunded": the label map has a wrong entry.
Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).
| Layer | Value | From | Read at |
|---|---|---|---|
| Database | "refunded" → "Refunded" | main: SELECT status FROM orders WHERE id = ? with [1003] → status |
2026-10-04 06:37:04 UTC |
| API | "refunded" → "Refunded" | GET /api/orders/1003 → $.status (200, as the page received it) | 2026-10-04 06:37:04 UTC |
| Page | "Cancelled" | /orders/1003 → [data-testid=status] (1 matched) |
2026-10-04 06:37:04 UTC |
Code: page public/assets/pages/order.js:24 · api src/routes.js:130 · db db/schema.sql:34 · labels src/order-status.js:8
Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/order-1003-status.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-orders-1003.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/order-1003-status.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/order-1003-status.html
orders-list-order: Orders are listed newest firstSort order differs between layers (strong): The page has the same 12 items as the API, in a different order. At position 1 it shows "1004" where "1012" belongs. Sorting by displayed text (dates sort alphabetically as text) or re-sorting already sorted data does this.
Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).
| Layer | Value | From | Read at |
|---|---|---|---|
| Database | [{"id":1012},{"id":1011},{"id":1010},{"id":1009},{"id":1008},{"id":1007},{"id":1006},{"id":1005},{"id":1004},{"id":10… → 12 items | main: SELECT id FROM orders ORDER BY placed_at DESC → id |
2026-10-04 06:37:05 UTC |
| API | [{"id":1012,"customer_name":"Nia Brooks","status":"pending","placed_at":"2026-04-26T18:45:00Z","total_cents":3110},{"… → 12 items | GET /api/orders → $.items (200, as the page received it) | 2026-10-04 06:37:05 UTC |
| Page | ["#1004","#1003","#1002","#1001","#1007","#1006","#1012","#1011","#1005","#1010","#1009","#1008"] → 12 items | /orders → [data-testid=order-row] (12 matched) |
2026-10-04 06:37:05 UTC |
Code: page public/assets/pages/orders.js:20 · api src/routes.js:117 · db db/schema.sql:35
Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/orders-list-order.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-orders.png
product-12-brand: Brand of the Stoneware Pour-Over Setnull, undefined or NaN shown as text (strong): The page shows the text "null" where the API has no value (null): a missing value is being written out as text instead of being left empty or shown as a placeholder.
Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).
| Layer | Value | From | Read at |
|---|---|---|---|
| Database | null → no value (null) | main: SELECT brand FROM products WHERE id = ? with [12] → brand |
2026-10-04 06:37:05 UTC |
| API | null → no value (null) | GET /api/products/12 → $.brand (200, as the page received it) | 2026-10-04 06:37:05 UTC |
| Page | "null" | /products/12 → [data-testid=brand] (1 matched) |
2026-10-04 06:37:06 UTC |
Code: page public/assets/pages/product.js:19 · api src/routes.js:87 · db db/schema.sql:14
Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/product-12-brand.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-products-12.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/product-12-brand.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/product-12-brand.html
product-12-rating: Average rating of the Stoneware Pour-Over SetMissing value shown as 0 (strong): The page shows 0.0 where the API has no value (null): "no value" is being shown as zero, for example with "?? 0" or "|| 0", which looks like a real number.
Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).
| Layer | Value | From | Read at |
|---|---|---|---|
| Database | null → no value (null) | main: SELECT avg_rating FROM products WHERE id = ? with [12] → avg_rating |
2026-10-04 06:37:06 UTC |
| API | null → no value (null) | GET /api/products/12 → $.avg_rating (200, as the page received it) | 2026-10-04 06:37:06 UTC |
| Page | "0.0" → 0.0 | /products/12 → [data-testid=rating] (1 matched) |
2026-10-04 06:37:06 UTC |
Code: page public/assets/pages/product.js:21 · api src/routes.js:87 · db db/schema.sql:18
Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/product-12-rating.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-products-12.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/product-12-rating.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/product-12-rating.html
product-7-sale-price: Sale price of the Walnut Desk LampCents vs dollars (strong): The page shows 1999.00 where the API has 19.99: exactly 100× too large. An amount in minor units (cents) is being shown as if it were in major units (dollars).
Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).
| Layer | Value | From | Read at |
|---|---|---|---|
| Database | 1999 → 19.99 | main: SELECT sale_price_cents FROM products WHERE id = ? with [7] → sale_price_cents |
2026-10-04 06:37:07 UTC |
| API | 1999 → 19.99 | GET /api/products/7 → $.sale_price_cents (200, as the page received it) | 2026-10-04 06:37:07 UTC |
| Page | "$1,999.00" → 1999.00 | /products/7 → [data-testid=sale-price] (1 matched) |
2026-10-04 06:37:07 UTC |
Code: page public/assets/pages/product.js:16 · api src/routes.js:87 · db db/schema.sql:16
Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/product-7-sale-price.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-products-7.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/product-7-sale-price.png, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/html/product-7-sale-price.html
products-list-complete: The product list shows every active productList cut off by pagination (strong): The page shows 20 of 23 items and nothing leads to the other 3. There is no pager. 20 is a typical page size, so the list stops at the first page of the API's results.
Where it first goes wrong: API → Page, so look in the frontend (between the data and what the page shows).
| Layer | Value | From | Read at |
|---|---|---|---|
| Database | [{"name":"Brass Cup Hook"},{"name":"Linen Napkin"},{"name":"Ceramic Bud Vase"},{"name":"Paper Pendant Shade"},{"name"… → 23 items | main: SELECT name FROM products WHERE active = 1 ORDER BY id → name |
2026-10-04 06:37:07 UTC |
| API | [{"id":1,"name":"Brass Cup Hook","category":"decor","price_cents":115,"sale_price_cents":null},{"id":2,"name":"Linen … → 20 items (total 23) | GET /api/products → $.items (200, as the page received it) | 2026-10-04 06:37:07 UTC |
| Page | ["Brass Cup Hook","Linen Napkin","Ceramic Bud Vase","Paper Pendant Shade","Oak Cutting Board","Wool Throw Blanket","W… → 20 items | /products → [data-testid=product-card] (20 matched) |
2026-10-04 06:37:08 UTC |
Code: page public/assets/pages/products.js:37 · api src/routes.js:60 · db db/schema.sql:10
Evidence: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/responses/products-list-complete.json, .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/screenshots/page-products.png
None.
None.
customer-4-orders: Dana Ruiz's orders, newest firstcustomers-list: Customer list, first page, newest firstdashboard-customer-count: Number of customers on the dashboarddashboard-last-restock: Time of the last restock on the dashboardorder-1001-total: Total of order 1001order-1002-status: Status of order 1002product-7-name: Name of product 7product-7-price: Price of the Walnut Desk LampValues on a page are found as visible text leaves that look like data (digits, currency, dates, percentages) or equal a value in a JSON response the page received; repeated items are grouped. A value is covered when a check reads its element (or one containing it); a repeated value (a list column) counts once and is covered only when every occurrence is read.
Not covered:
[data-testid="cart-row"] > td:nth-of-type(1) > a: "Brass Cup Hook" (text, shown 3 times, 0 read); in the JSON at GET /api/cart $.items[0].name[data-testid="cart-row"] > td:nth-of-type(2): "$1.15" (money, shown 3 times, 0 read); in the JSON at GET /api/cart $.items[0].unit_price[data-testid="cart-row"] > td:nth-of-type(3): "3" (number, shown 3 times, 0 read); in the JSON at GET /api/cart $.items[0].quantity[data-testid="cart-row"] > td:nth-of-type(4): "$3.45" (money, shown 3 times, 0 read)Not covered:
[data-testid="customer-name"]: "Dana Ruiz" (text); in the JSON at GET /api/customers/4 $.namemain > p: "dana.ruiz@example.com · joined Jul 23, 2025" (date)[data-testid="customer-order-row"] > td:nth-of-type(2): "Apr 15, 2026" (date, shown 3 times, 0 read)[data-testid="customer-order-row"] > td:nth-of-type(4): "$55.00" (money, shown 3 times, 0 read); in the JSON at GET /api/customers/4 $.orders[0].total_centsNot covered:
[data-testid="customer-row"] > td:nth-of-type(1) > a: "Yusuf Demir" (text, shown 10 times, 0 read); in the JSON at GET /api/customers?limit=10&offset=0 $.items[0].name[data-testid="customer-row"] > td:nth-of-type(3): "Mar 28, 2026" (date, shown 10 times, 0 read)[data-testid="pager"] > span:nth-of-type(2): "Page 1 of 3" (number)Not covered:
main > section > div:nth-of-type(3) > p:nth-of-type(2): "Ash Dining Chair, +20" (number); in the JSON at GET /api/restocks/latest $.quantityNot covered:
[data-testid="product-name"]: "Paper Pendant Shade" (text, shown 5 times, 0 read); in the JSON at GET /api/products?category=lighting $.items[0].name[data-testid="price"]: "$32.00" (money, shown 5 times, 0 read); in the JSON at GET /api/products?category=lighting $.items[0].price_centsNot covered:
main > h1: "Order #1001" (number); in the JSON at GET /api/orders/1001 $.idmain > dl:nth-of-type(1) > dd:nth-of-type(1) > a: "Dana Ruiz" (text); in the JSON at GET /api/orders/1001 $.customer.namemain > table > tbody > tr > td:nth-of-type(1) > a: "Walnut Desk Lamp" (text, shown 2 times, 0 read); in the JSON at GET /api/orders/1001 $.items[0].namemain > table > tbody > tr > td:nth-of-type(2): "1" (number, shown 2 times, 0 read); in the JSON at GET /api/orders/1001 $.items[0].quantitymain > table > tbody > tr > td:nth-of-type(3): "$24.99" (money, shown 2 times, 0 read); in the JSON at GET /api/orders/1001 $.items[0].unit_price_cents[data-testid="subtotal"]: "$27.29" (money); in the JSON at GET /api/orders/1001 $.subtotal_cents[data-testid="shipping"]: "$5.00" (money); in the JSON at GET /api/orders/1001 $.shipping_centsNot covered:
main > h1: "Order #1002" (number); in the JSON at GET /api/orders/1002 $.idmain > dl:nth-of-type(1) > dd:nth-of-type(1) > a: "Ivan Petrov" (text); in the JSON at GET /api/orders/1002 $.customer.name[data-testid="placed-at"]: "Mar 20, 2026, 3:05 PM" (date)[data-testid="delivery-date"]: "March 23, 2026" (date)main > table > tbody > tr > td:nth-of-type(1) > a: "Wool Throw Blanket" (text); in the JSON at GET /api/orders/1002 $.items[0].namemain > table > tbody > tr > td:nth-of-type(2): "1" (number); in the JSON at GET /api/orders/1002 $.items[0].quantitymain > table > tbody > tr > td:nth-of-type(3): "$79.00" (money); in the JSON at GET /api/orders/1002 $.items[0].unit_price_cents[data-testid="subtotal"]: "$79.00" (money); in the JSON at GET /api/orders/1002 $.items[0].unit_price_cents[data-testid="shipping"]: "$0.00" (money)[data-testid="order-total"] > strong: "$66.50" (money); in the JSON at GET /api/orders/1002 $.total_centsNot covered:
main > h1: "Order #1003" (number); in the JSON at GET /api/orders/1003 $.idmain > dl:nth-of-type(1) > dd:nth-of-type(1) > a: "Dana Ruiz" (text); in the JSON at GET /api/orders/1003 $.customer.name[data-testid="placed-at"]: "Mar 27, 2026, 1:45 PM" (date)main > table > tbody > tr > td:nth-of-type(1) > a: "Ceramic Bud Vase" (text); in the JSON at GET /api/orders/1003 $.items[0].namemain > table > tbody > tr > td:nth-of-type(2): "1" (number); in the JSON at GET /api/orders/1003 $.items[0].quantitymain > table > tbody > tr > td:nth-of-type(3): "$18.50" (money); in the JSON at GET /api/orders/1003 $.items[0].unit_price_cents[data-testid="subtotal"]: "$18.50" (money); in the JSON at GET /api/orders/1003 $.items[0].unit_price_cents[data-testid="shipping"]: "$5.00" (money); in the JSON at GET /api/orders/1003 $.shipping_cents[data-testid="order-total"] > strong: "$23.50" (money); in the JSON at GET /api/orders/1003 $.total_centsNot covered:
[data-testid="order-row"] > td:nth-of-type(2): "Lena Fischer" (text, shown 12 times, 0 read); in the JSON at GET /api/orders $.items[8].customer_name[data-testid="order-row"] > td:nth-of-type(3): "Mar 29, 2026" (date, shown 12 times, 0 read)[data-testid="order-row"] > td:nth-of-type(5): "$52.90" (money, shown 12 times, 0 read); in the JSON at GET /api/orders $.items[8].total_centsNot covered:
main > p > a: "Kitchen" (text); in the JSON at GET /api/products/12 $.category_name[data-testid="product-name"]: "Stoneware Pour-Over Set" (text); in the JSON at GET /api/products/12 $.name[data-testid="price"]: "$36.00" (money); in the JSON at GET /api/products/12 $.price_centsmain > dl > dd:nth-of-type(3): "/ 5" (number)main > dl > dd:nth-of-type(3) > span:nth-of-type(2): "(0 reviews)" (number)[data-testid="stock"]: "14 in stock" (number); in the JSON at GET /api/products/12 $.stockNot covered:
main > p > a: "Lighting" (text); in the JSON at GET /api/products/7 $.category_name[data-testid="brand"]: "Lumen Works" (text); in the JSON at GET /api/products/7 $.brandmain > dl > dd:nth-of-type(4): "/ 5" (number)[data-testid="rating"]: "4.3" (number); in the JSON at GET /api/products/7 $.avg_ratingmain > dl > dd:nth-of-type(4) > span:nth-of-type(2): "(17 reviews)" (number)[data-testid="stock"]: "12 in stock" (number); in the JSON at GET /api/products/7 $.stockNot covered:
[data-testid="result-count"]: "23 results" (number); in the JSON at GET /api/products $.total[data-testid="price"]: "$1.15" (money, shown 20 times, 0 read); in the JSON at GET /api/products $.items[0].price_cents$.items[*].product_id, $.items[*].name, $.items[*].unit_price, $.items[*].quantity$.items[*].id, $.items[*].name, $.items[*].created_at, $.limit, $.offset$.id, $.name, $.email, $.created_at, $.orders[*].status, $.orders[*].placed_at, $.orders[*].total_cents$.items[*].customer_name, $.items[*].status, $.items[*].placed_at, $.items[*].total_cents$.id, $.status, $.customer.id, $.customer.name, $.items[*].product_id, $.items[*].name, $.items[*].quantity, $.items[*].unit_price_cents, $.subtotal_cents, $.discount_cents, $.shipping_cents$.id, $.placed_at, $.delivery_date, $.customer.id, $.customer.name, $.items[*].product_id, $.items[*].name, $.items[*].quantity, $.items[*].unit_price_cents, $.subtotal_cents, $.shipping_cents, $.total_cents$.id, $.placed_at, $.delivery_date, $.customer.id, $.customer.name, $.items[*].product_id, $.items[*].name, $.items[*].quantity, $.items[*].unit_price_cents, $.subtotal_cents, $.discount_cents, $.shipping_cents, $.total_cents$.items[*].id, $.items[*].category, $.items[*].price_cents, $.items[*].sale_price_cents, $.limit, $.offset$.id, $.name, $.category, $.category_name, $.price_cents, $.sale_price_cents, $.stock, $.review_count$.id, $.category, $.category_name, $.brand, $.stock, $.avg_rating, $.review_count$.total$.product_id, $.product_name, $.quantityReport: .samethru/runs/2026-10-04T06-36-54-175Z-c0f5/report.md (and run.json)
These bugs are easy to miss, because each part of the app looks fine on its own. Every problem Samethru finds is matched to one of these kinds, with a plain explanation. Each example below is real Samethru output: what the page showed, and what it should have shown.
Sale price, /products/7
$1,999.00Should be: $19.99, saved as 1999 cents
cents-vs-dollars
A price saved in cents is shown as if it were dollars, so it’s 100 times too big (or the other way round).
Cart total, /cart
$35.79Should be: $35.80
rounding-error
A total is off by a cent because of the way the code adds up or rounds the numbers.
Discount, /orders/1002
$12.5Should be: $12.50
wrong-decimal-places
The amount is right, but it’s shown with the wrong number of decimals.
Low-stock products, the dashboard
4Should be: 2. The database changed about 2.2 s after the copy was made, and the copy was never updated.
stale-cache
A saved copy (a cache) keeps showing an old number after the real one changed.
Order time, /orders/1001
Mar 14, 2026, 6:30 PMShould be: 2:30 PM New York time (18:30 UTC)
timezone-shift
A time is off by whole hours because it was converted to the wrong time zone, or not converted.
Delivery date, /orders/1001
March 16, 2026Should be: March 17, 2026
date-off-by-one
A date shows one day early or late, because a time zone moved it across midnight.
Brand, /products/12
nullShould be: Blank. No brand is saved.
null-as-text
A value that’s missing is shown as the word “null” (or “undefined”, “NaN”) instead of being left blank.
Rating, /products/12
0.0Should be: No rating. There are no reviews yet.
missing-as-zero
A value that’s missing is shown as 0, which looks like a real number.
Product list, /products
Should be: 23 products. The last 3 can’t be reached.
pagination-cutoff
A list shows only the first page of results, and there’s no way to see the rest.
Lighting category, /products?category=lighting
23 resultsShould be: 5 results
wrong-filtered-count
A count ignores the filter and shows the total of everything instead.
Orders, newest first, /orders
#1004#1003#1002#1001Should be: #1012, #1011, #1010, #1009
sort-order-mismatch
A list has the right items, in the wrong order.
Order status, /orders/1003
CancelledShould be: Refunded
wrong-enum-label
A status is shown with the wrong name.
Every check ends with one of these four. Samethru never says a value is fine unless the check actually ran and the values matched. If a check couldn’t run, or couldn’t decide, it says so.
PASS
It matches
The check ran, and the value is the same where it’s saved, sent and shown.
Example: the price is 2499 cents in the database and the API, and $24.99 on the page.
FAIL
It doesn’t match
The check ran and found a difference. You get the value at each stop, where it came from, and the kind of bug.
Example: the sale price is 1999 cents in the API but $1,999.00 on the page.
NOT_RUN
It couldn’t check
Something stopped the check from running, such as the app not being started. It says why:
couldn't load http://127.0.0.1:4321 (connection refused); is the app running?
INCONCLUSIVE
It couldn’t tell
The check ran, but couldn’t find what it needed to decide. It says what was missing:
Page: no element matches "[data-testid=list-price]"
So a check that didn’t run never slips through as a pass. When nothing failed but some checks didn’t run or couldn’t decide, the report says “Not every check passed: NOT_RUN and INCONCLUSIVE checks are not passes.” and samethru run exits with 2, so an automated build stops too.
Checking 20 values doesn’t mean much if your pages show 77. Every report counts how many values on each page were checked, and lists every one that wasn’t, so nothing is quietly assumed to be fine.
20 of 77values checked across 12 pages (26%)
21 of 102pieces of data sent by the server checked, across 13 API endpoints (21%)
checked not checked
/3 of 4/cart1 of 5/customers1 of 4/customers/41 of 5/orders1 of 4/orders/10013 of 10/orders/10022 of 12/orders/10031 of 10/products1 of 3/products?category=lighting1 of 3/products/122 of 8/products/73 of 9On the example page at the top, these 6 values weren’t checked, and the report lists them by name:
“Lighting”“Lumen Works”“/ 5”“4.3”“(17 reviews)”“12 in stock”You can also set a minimum. With --min-coverage 60, the run fails when less than 60% of the values on your pages are checked.
You need an app you can run on your computer, Node.js 22.13 or later, and an AI coding tool: Claude Code, Cursor, Codex or Gemini CLI. Four steps:
npx samethru initRun this in your project’s folder. It finds the AI tools on your computer and connects Samethru to the ones you choose. It shows every change before making it and asks first. (--dry-run shows the changes without making any.)
npx samethru init
AI tools on this machine:
✓ Claude Code (~/.claude, ~/.claude.json)
· Cursor: not found
✓ Codex (codex on PATH, ~/.codex)
· Gemini CLI: not found
── MCP server and .gitignore ───────────────────────────────────
• Add the samethru server to Claude Code (this project): .mcp.json
• Add the samethru server to Codex (this project): .codex/config.toml
• Keep .samethru/runs/ and .samethru/auth.json out of git: .gitignore
✓ Wrote .mcp.json
✓ Wrote .codex/config.toml
✓ Wrote .gitignore
Cut from the output: [ cut: the config, the audit skill, AGENTS.md and the browser check ]
Next: start your app, then ask your assistant to "run a Samethru audit". Check the setup any time with `npx samethru doctor`.
Then tell it where your app runs and where its database is, in .samethru/config.yaml. Samethru only ever reads your database, never changes it, and passwords stay in environment variables:
version: 1
baseUrl: http://localhost:3000
browser:
timezone: America/New_York
databases:
main:
driver: postgres # or sqlite, with path: db/development.sqlite3
url: ${env:DATABASE_URL}npx samethru doctorStart your app the way you normally do, then run this. It tries every part, from the browser to the database connection, and tells you what to fix. If your pages need a login, run npx samethru login first and log in as usual.
npx samethru doctor
Cut from the output: [ cut: the project folder ]
✓ Node.js: 23.11.0
✓ Project: .samethru/config.yaml is valid; 20 saved checks
✓ Claude Code: uses Samethru (.mcp.json)
✓ Codex: uses Samethru (.codex/config.toml); Codex reads it only in trusted projects
✓ MCP server: starts; offers 7 tools and the audit prompt
✓ Browser: chrome 154.0.8037.97 (headless)
✓ Database main (sqlite): connected; read-only verified (runtime self-test: a readOnly connection to a scratch database refused CREATE TABLE; opened with readOnly: true; PRAGMA query_only = ON, read back as 1)
✓ App: http://127.0.0.1:4321 answered (200)
Everything looks good.
“Run a Samethru audit.”
Type that into your AI tool, or ask it to check one page. Your assistant reads your code to find where each value on your pages comes from, and writes a small check for each one. Samethru then runs the checks against your app and reports what it found. (In the Claude Code CLI you can also type /mcp__samethru__audit, and in Gemini CLI /audit.)
npx samethru run --min-coverage 60The checks are saved as small files in your project (.samethru/checks/), so you can run them again without AI, for example every time you push code to CI. Start your app and a test database first. The exit code says how it went:
| Exit code | Meaning |
|---|---|
0 | Every check ran and passed. |
1 | At least one check found a wrong value. |
2 | Nothing was wrong, but something didn’t run or couldn’t decide, no check ran at all, or too few values were checked (below --min-coverage). |
3 | Something is wrong with the setup or the command. |
Samethru runs on your own computer. It only reads your data; it never changes it.
SELECT queries, checks that the connection really is read-only, and doctor warns you if the database user could make changes.allowedHosts..samethru/auth.json), it’s kept out of git, and it’s never shown to your assistant.Samethru makes no network calls of its own: no tracking, no update checks, and no calls to AI models. It only talks to the app and database you point it at.
One thing to know about your AI tool:
What it returns to your coding assistant (query rows, page text, short response previews) is handled like any other tool output. Most assistants send tool output to their model provider, so treat it the way you treat your assistant reading your files. To keep that small, Samethru returns 20 rows by default and keeps previews short. Full responses, screenshots and evidence are written to
.samethru/runs/on your disk, and only their paths are returned. Secrets (environment-variable values, the saved login’s cookies) are removed from everything it outputs.
For now, Samethru can only check values it can reach by opening a page’s address. It can’t click buttons, switch tabs, fill in forms or press “load more” yet.