DO-1 (regression): the orderedList start hardening `Number(x)||1` let a
negative/fractional start through into the marker ("-3.", "2.5.") — marked
can't tokenize it, so the whole list re-imported as a paragraph (structure
corruption); start=0 churned. Both the markdown and raw-HTML export paths
now use `Number.isInteger(raw) && raw > 1 ? raw : 1`, so any degenerate
start collapses to the default "1." markers / bare <ol> (list always valid).
New ordered-list-start-normalization test pins {0,-3,2.5} → valid start=1
list, byte-stable; the round-trip fuzz keeps to integers >=2 (num 2,3,5,42).
DO-2 (nightly was non-functional): NUM_RUNS=5000 OOM'd the worker and the
crash was misreported as a "counterexample" issue whose prefix-dedup then
locked out all future issues. Reworked to shard 8 fresh vitest processes
(600 runs each, distinct seeds, --max-old-space-size) so deep fuzzing
never OOMs; a failing shard's output is preserved, and issue creation
discriminates a real fast-check counterexample from an infra/OOM failure
(distinct titles + scoped dedup). The two issue steps use `always() &&`
so they actually run on the failure path.
DO-3: envInt extracted to test/generative/env-int.ts + unit-tested.
DO-4: nightly dispatch inputs go through env: (no ${{ }} in run:).
DO-5: attr-arbitraries.ts docblock synced (column.width/orderedList.start
are fixed+fuzzed, not pinned it.fails).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,14 +2,24 @@ name: Nightly property fuzz
|
||||
|
||||
# The daily heavy property run for the ProseMirror<->Markdown converter
|
||||
# (packages/prosemirror-markdown). The PR/CI test run keeps NUM_RUNS modest to
|
||||
# stay under budget; this cron cranks it up (~5000) with a RANDOM seed to hunt
|
||||
# stay under budget; this cron cranks up total coverage with random seeds to hunt
|
||||
# for deeper round-trip counterexamples than a fixed-seed PR run can reach.
|
||||
#
|
||||
# Counterexample -> fixture workflow: when a run fails, fast-check prints the
|
||||
# SHRUNK minimal counterexample plus the reproducing seed. This job files a
|
||||
# Gitea issue containing that seed + counterexample. A human then commits the
|
||||
# shrunk doc as a PERMANENT fixture under
|
||||
# packages/prosemirror-markdown/test/fixtures/counterexamples/ with a case in
|
||||
# WHY SHARDING: a single mega-run (~10000 fast-check runs) OOMs the vitest worker
|
||||
# (empirically ~1625 runs -> "JS heap out of memory", ~2GB) because heap
|
||||
# accumulates across the whole property run in one process. Instead this job runs
|
||||
# SHARDS fresh vitest processes, each a MODERATE per-shard count with a DISTINCT
|
||||
# derived seed, so total coverage ~= SHARDS x PER_SHARD_NUM_RUNS across processes
|
||||
# that never accumulate heap. On the first failing shard we stop and keep that
|
||||
# shard's output for triage.
|
||||
#
|
||||
# Counterexample -> fixture workflow: when a shard fails, fast-check prints the
|
||||
# SHRUNK minimal counterexample plus the reproducing seed. This job files a Gitea
|
||||
# issue containing that seed + counterexample ONLY when the output actually holds
|
||||
# a fast-check counterexample; an infra failure (OOM/tsc/install, no
|
||||
# counterexample) is filed under a DISTINCT title so it can never poison the
|
||||
# counterexample dedup. A human then commits the shrunk doc as a PERMANENT fixture
|
||||
# under packages/prosemirror-markdown/test/fixtures/counterexamples/ with a case in
|
||||
# counterexamples.test.ts, and FIXES the converter (never weakens a property to
|
||||
# hide the bug). See packages/prosemirror-markdown/README.md.
|
||||
|
||||
@@ -20,11 +30,11 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
num_runs:
|
||||
description: 'fast-check runs per property'
|
||||
description: 'fast-check runs PER SHARD (8 shards run in sequence)'
|
||||
required: false
|
||||
default: '5000'
|
||||
default: '600'
|
||||
seed:
|
||||
description: 'fast-check seed (empty = random)'
|
||||
description: 'base fast-check seed (empty = random); shard i uses base+i'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
@@ -57,60 +67,152 @@ jobs:
|
||||
# against source without the package's build/. Skipping the build also
|
||||
# keeps a tsc build error from masquerading as a property-test failure and
|
||||
# filing a bogus counterexample issue.
|
||||
- name: Resolve seed and run count
|
||||
- name: Resolve base seed and per-shard run count
|
||||
id: params
|
||||
# Dispatch inputs are read via env (NOT interpolated into the shell body)
|
||||
# to avoid script injection through a crafted input value.
|
||||
env:
|
||||
SEED_INPUT: ${{ inputs.seed }}
|
||||
NUM_RUNS_INPUT: ${{ inputs.num_runs }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEED="${{ inputs.seed }}"
|
||||
SEED="${SEED_INPUT:-}"
|
||||
# Empty seed (cron, or a dispatch that left it blank) -> random. Combine
|
||||
# two RANDOMs so the seed spans more than RANDOM's 0..32767 range.
|
||||
[ -z "$SEED" ] && SEED=$(( (RANDOM << 15) | RANDOM ))
|
||||
NUM_RUNS="${{ inputs.num_runs || '5000' }}"
|
||||
NUM_RUNS="${NUM_RUNS_INPUT:-}"
|
||||
[ -z "$NUM_RUNS" ] && NUM_RUNS=600
|
||||
echo "seed=$SEED" >> "$GITHUB_OUTPUT"
|
||||
echo "num_runs=$NUM_RUNS" >> "$GITHUB_OUTPUT"
|
||||
echo "Property fuzz: PROPERTY_SEED=$SEED PROPERTY_NUM_RUNS=$NUM_RUNS"
|
||||
echo "Sharded property fuzz: BASE_SEED=$SEED PER_SHARD_NUM_RUNS=$NUM_RUNS SHARDS=8"
|
||||
|
||||
- name: Run generative property suite
|
||||
- name: Run generative property suite (sharded)
|
||||
id: fuzz
|
||||
env:
|
||||
PROPERTY_SEED: ${{ steps.params.outputs.seed }}
|
||||
PROPERTY_NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
||||
BASE_SEED: ${{ steps.params.outputs.seed }}
|
||||
PER_SHARD_NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
||||
SHARDS: '8'
|
||||
run: |
|
||||
set -o pipefail
|
||||
pnpm --filter @docmost/prosemirror-markdown exec \
|
||||
vitest run test/generative/ 2>&1 | tee property-output.txt
|
||||
set -uo pipefail
|
||||
# Give each fresh process headroom, but rely on SHARDING (not a big heap)
|
||||
# to avoid OOM: a moderate per-shard count in a process that starts clean.
|
||||
export NODE_OPTIONS=--max-old-space-size=4096
|
||||
: > property-output.txt
|
||||
FAILED=0
|
||||
FAIL_SEED=""
|
||||
i=0
|
||||
while [ "$i" -lt "$SHARDS" ]; do
|
||||
SHARD_SEED=$(( BASE_SEED + i ))
|
||||
echo "=== shard $((i + 1))/$SHARDS: PROPERTY_SEED=$SHARD_SEED PROPERTY_NUM_RUNS=$PER_SHARD_NUM_RUNS ==="
|
||||
# tee OVERWRITES property-output.txt each shard; since we break on the
|
||||
# first failure, the file ends up holding exactly the failing shard's
|
||||
# output (which carries the shrunk counterexample + reproducing seed).
|
||||
if PROPERTY_SEED="$SHARD_SEED" PROPERTY_NUM_RUNS="$PER_SHARD_NUM_RUNS" \
|
||||
pnpm --filter @docmost/prosemirror-markdown exec \
|
||||
vitest run test/generative/ 2>&1 | tee property-output.txt; then
|
||||
echo "shard $((i + 1)) passed"
|
||||
else
|
||||
echo "shard $((i + 1)) FAILED (seed=$SHARD_SEED) — stopping; keeping its output"
|
||||
FAILED=1
|
||||
FAIL_SEED="$SHARD_SEED"
|
||||
break
|
||||
fi
|
||||
i=$(( i + 1 ))
|
||||
done
|
||||
echo "failed=$FAILED" >> "$GITHUB_OUTPUT"
|
||||
echo "fail_seed=$FAIL_SEED" >> "$GITHUB_OUTPUT"
|
||||
exit "$FAILED"
|
||||
|
||||
# On failure fast-check has already printed the shrunk counterexample and
|
||||
# its reproducing seed into property-output.txt. File a Gitea issue with
|
||||
# the seed, run count and the tail of that output so a human can turn the
|
||||
# counterexample into a permanent fixture (see the header comment).
|
||||
- name: File issue on failure
|
||||
if: failure()
|
||||
# A GENUINE counterexample: fast-check printed a shrunk minimal case and its
|
||||
# reproducing seed into property-output.txt. File a dedup-guarded issue whose
|
||||
# title prefix is UNIQUE to counterexamples, so an infra failure (handled by
|
||||
# the next step under a different title) can never poison this dedup.
|
||||
- name: File counterexample issue
|
||||
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
||||
# so a bare `if:` (implicitly success() && ...) would skip this step
|
||||
# exactly when it must run. always() lets it run on the failure path.
|
||||
if: always() && steps.fuzz.outputs.failed == '1'
|
||||
env:
|
||||
SEED: ${{ steps.params.outputs.seed }}
|
||||
FAIL_SEED: ${{ steps.fuzz.outputs.fail_seed }}
|
||||
NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TITLE_PREFIX: 'Nightly property counterexample'
|
||||
run: |
|
||||
set -uo pipefail
|
||||
TITLE="Nightly property test failure (seed=${SEED})"
|
||||
# Discriminate counterexample vs infra failure by the fast-check
|
||||
# signature. No signature -> leave it to the infra-failure step.
|
||||
if ! grep -Eq 'Property failed after|Counterexample' property-output.txt; then
|
||||
echo "No fast-check counterexample signature — infra failure, handled by the next step."
|
||||
exit 0
|
||||
fi
|
||||
TITLE="${TITLE_PREFIX} (seed=${FAIL_SEED})"
|
||||
|
||||
# Best-effort dedup: skip if an open issue with the shared title prefix
|
||||
# already exists. A failure of this check must NOT block issue creation.
|
||||
# Best-effort dedup: skip if an open issue with the counterexample title
|
||||
# prefix already exists. A failure of this check must NOT block creation.
|
||||
EXISTING=""
|
||||
if EXISTING=$(curl -sS \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
||||
if printf '%s' "$EXISTING" \
|
||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);process.exit(a.some(i=>typeof i.title==="string"&&i.title.startsWith("Nightly property test failure"))?0:1)})'; then
|
||||
echo "An open 'Nightly property test failure' issue already exists — skipping creation."
|
||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const p=process.env.TITLE_PREFIX;process.exit(a.some(i=>typeof i.title==="string"&&i.title.startsWith(p))?0:1)})'; then
|
||||
echo "An open '${TITLE_PREFIX}' issue already exists — skipping creation."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build the JSON body with the test output SAFELY escaped (never hand-
|
||||
# interpolate the counterexample into JSON).
|
||||
BODY_TEXT=$(printf 'The nightly property fuzz run failed.\n\n- seed: `%s`\n- NUM_RUNS: `%s`\n- run: %s\n\nReproduce locally:\n\n```\nPROPERTY_SEED=%s PROPERTY_NUM_RUNS=%s pnpm --filter @docmost/prosemirror-markdown exec vitest run test/generative/\n```\n\nfast-check shrinks the failure to a minimal counterexample. Commit it as a permanent fixture under `packages/prosemirror-markdown/test/fixtures/counterexamples/` + a case in `counterexamples.test.ts`, then FIX the converter (do not weaken a property). See `packages/prosemirror-markdown/README.md`.\n\nTail of the test output (contains the shrunk counterexample):\n\n```\n%s\n```\n' \
|
||||
"$SEED" "$NUM_RUNS" "$RUN_URL" "$SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)")
|
||||
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed with a fast-check counterexample.\n\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nReproduce locally:\n\n```\nPROPERTY_SEED=%s PROPERTY_NUM_RUNS=%s pnpm --filter @docmost/prosemirror-markdown exec vitest run test/generative/\n```\n\nfast-check shrinks the failure to a minimal counterexample. Commit it as a permanent fixture under `packages/prosemirror-markdown/test/fixtures/counterexamples/` + a case in `counterexamples.test.ts`, then FIX the converter (do not weaken a property). See `packages/prosemirror-markdown/README.md`.\n\nTail of the test output (contains the shrunk counterexample):\n\n```\n%s\n```\n' \
|
||||
"$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)")
|
||||
|
||||
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
||||
'{title: $title, body: $body}' > payload.json
|
||||
|
||||
curl -sS -X POST \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d @payload.json
|
||||
|
||||
# An INFRA failure (OOM, tsc, install) has NO counterexample signature. File
|
||||
# it under a DISTINCT title so it is visible but keeps the counterexample
|
||||
# dedup (above) uncontaminated — a real counterexample can still file even
|
||||
# while an infra issue is open.
|
||||
- name: File infra failure issue
|
||||
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
||||
# so a bare `if:` (implicitly success() && ...) would skip this step
|
||||
# exactly when it must run. always() lets it run on the failure path.
|
||||
if: always() && steps.fuzz.outputs.failed == '1'
|
||||
env:
|
||||
FAIL_SEED: ${{ steps.fuzz.outputs.fail_seed }}
|
||||
NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TITLE_PREFIX: 'Nightly property run infra failure'
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# Only file when there is NO counterexample signature (else the
|
||||
# counterexample step owns it).
|
||||
if grep -Eq 'Property failed after|Counterexample' property-output.txt; then
|
||||
echo "Counterexample present — owned by the counterexample step."
|
||||
exit 0
|
||||
fi
|
||||
TITLE="${TITLE_PREFIX} (seed=${FAIL_SEED})"
|
||||
|
||||
EXISTING=""
|
||||
if EXISTING=$(curl -sS \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
||||
if printf '%s' "$EXISTING" \
|
||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const p=process.env.TITLE_PREFIX;process.exit(a.some(i=>typeof i.title==="string"&&i.title.startsWith(p))?0:1)})'; then
|
||||
echo "An open '${TITLE_PREFIX}' issue already exists — skipping creation."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed WITHOUT a fast-check counterexample (infra failure: OOM / build / install). This is NOT a converter round-trip bug.\n\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nInvestigate the run log (memory, dependency install, or a tsc/import error). The nightly counterexample dedup is intentionally separate from this issue.\n\nTail of the test output:\n\n```\n%s\n```\n' \
|
||||
"$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$(tail -n 120 property-output.txt)")
|
||||
|
||||
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
||||
'{title: $title, body: $body}' > payload.json
|
||||
|
||||
@@ -640,9 +640,12 @@ export function convertProseMirrorToMarkdown(
|
||||
// ("5." starts the list at 5), and marked parses that back into
|
||||
// <ol start="5">. Emitting `${start + index}.` keeps a start=5 list as
|
||||
// "5.","6.",… while a default (start=1) list stays "1.","2.",… See #351.
|
||||
// `Number(...) || 1` coerces a numeric-or-absent start and guards against
|
||||
// a stray non-numeric `start` string-concatenating into the marker.
|
||||
const start = Number(node.attrs?.start) || 1;
|
||||
// Only an INTEGER ≥ 2 is a valid explicit start; collapse everything else
|
||||
// (absent, non-number, 0, negative, fractional) to 1. A negative/fractional
|
||||
// start would otherwise emit an untokenizable marker (e.g. "-3.", "2.5.")
|
||||
// that marked cannot parse, re-importing the whole list as a PARAGRAPH.
|
||||
const raw = node.attrs?.start;
|
||||
const start = Number.isInteger(raw) && (raw as number) > 1 ? (raw as number) : 1;
|
||||
return nodeContent
|
||||
.map((item: any, index: number) =>
|
||||
processListItem(item, `${start + index}.`),
|
||||
@@ -1413,7 +1416,11 @@ export function convertProseMirrorToMarkdown(
|
||||
// Carry a non-1 `start` on the raw-HTML path (columns/spanned cells) via
|
||||
// the <ol start="N"> attribute, which the tiptap parser reads back into
|
||||
// attrs.start. A default (start=1) list stays a bare <ol>. See #351.
|
||||
const start = block.attrs?.start ?? 1;
|
||||
// Use the SAME integer-≥-2 guard as the markdown path: a fractional start
|
||||
// would emit `start="2.5"` → parseInt→2 on import (path divergence), and a
|
||||
// 0/negative start is not a valid explicit start either.
|
||||
const raw = block.attrs?.start;
|
||||
const start = Number.isInteger(raw) && (raw as number) > 1 ? (raw as number) : 1;
|
||||
const startAttr = start > 1 ? ` start="${start}"` : "";
|
||||
return `<ol${startAttr}>${children
|
||||
.map((li: any) => `<li>${blockChildrenToHtml(li)}</li>`)
|
||||
|
||||
@@ -37,14 +37,19 @@
|
||||
* tagged `// ACCEPTED:` inline. Freezing them is correct — there is
|
||||
* nothing to preserve in the target format.
|
||||
*
|
||||
* (b) PINNED BUG — the attribute IS representable in markdown but the
|
||||
* converter drops it anyway (a real defect). These are NOT silently
|
||||
* frozen: each is captured as a LOUD `it.fails` counterexample in
|
||||
* test/fixtures/counterexamples/ + counterexamples.test.ts, and the
|
||||
* freeze here only keeps the P1/P2 union green until a MAINTAINER rules
|
||||
* on accept-vs-fix (the epic guardrail reserves that call). These:
|
||||
* `column.width` (parseFloat drops `%`), `orderedList.start` (non-1
|
||||
* start renders as `1.`). Tagged `// PINNED-BUG:` inline.
|
||||
* (b) FIXED & VALUE-FUZZED — attributes that were once PINNED converter
|
||||
* bugs (representable in markdown but dropped) and are now FIXED in
|
||||
* src/, so they are value-fuzzed here at legal non-default values like
|
||||
* any healthy attr. `orderedList.start` (the non-1 start once rendered
|
||||
* as `1.`; the converter now emits the start marker / `<ol start="N">`)
|
||||
* and `column.width` (a unitless flex-grow number that round-trips via
|
||||
* parseFloat) are both fuzzed in OVERRIDES below. The former held-out
|
||||
* `it.fails` cases are gone; `ordered-list-start.json` +
|
||||
* counterexamples.test.ts now stand as PASSING regression pins (per the
|
||||
* epic guardrail, the minimal doc stays forever to guard re-regression).
|
||||
* The #351 media-family sizing attrs (image/video/youtube/pdf/drawio/
|
||||
* excalidraw/embed width/height/size/aspectRatio) are likewise fuzzed
|
||||
* now that they ride round-trip-safely in the per-node `<!--…-->` JSON.
|
||||
*
|
||||
* (c) DEFERRED-BUG — representable AND round-trips, frozen only because the
|
||||
* flat generator can't yet build a valid instance. Table
|
||||
@@ -64,8 +69,10 @@
|
||||
* number re-parses as a string), EXCEPT `embed.width/height` which the
|
||||
* embed schema keeps numeric — handled per-attr.
|
||||
*
|
||||
* Both PINNED-BUG attrs (`column.width` P2 churn, `orderedList.start` P1 loss)
|
||||
* are captured as committed `it.fails` counterexamples — NOT hidden here.
|
||||
* The two former PINNED-BUG attrs (`column.width` P2 churn, `orderedList.start`
|
||||
* P1 loss) are now FIXED and value-fuzzed; `ordered-list-start.json` in
|
||||
* counterexamples.test.ts is a permanent PASSING regression pin, not an
|
||||
* `it.fails` hold-out.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
import { getSchema } from '@tiptap/core';
|
||||
@@ -142,7 +149,7 @@ const OVERRIDES: Record<string, AttrPolicy> = {
|
||||
// FIXED (#351): the converter now emits the start marker ("5." / <ol start="5">)
|
||||
// and it round-trips, so the start number is value-fuzzed. See
|
||||
// counterexamples.test.ts (ordered-list-start.json) for the regression pin.
|
||||
'orderedList.start': { always: true, arb: num(2, 3, 5) },
|
||||
'orderedList.start': { always: true, arb: num(2, 3, 5, 42) },
|
||||
'orderedList.type': { frozen: true }, // ACCEPTED: a/A/i markers not expressible in GFM
|
||||
'taskItem.checked': { always: true, arb: fc.constant(true) }, // boolean, default false
|
||||
// ── codeBlock ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { envInt } from './env-int.js';
|
||||
|
||||
// Unit coverage for the shared PROPERTY_SEED / PROPERTY_NUM_RUNS parser. The key
|
||||
// contract is that an explicit "0" is honored (a valid fast-check seed) while
|
||||
// empty/absent/non-numeric fall back to the default.
|
||||
describe('envInt', () => {
|
||||
it('honors an explicit "0" (does not fall back)', () => {
|
||||
expect(envInt('0', 42)).toBe(0);
|
||||
});
|
||||
it('falls back on empty string', () => {
|
||||
expect(envInt('', 42)).toBe(42);
|
||||
});
|
||||
it('falls back on undefined', () => {
|
||||
expect(envInt(undefined, 42)).toBe(42);
|
||||
});
|
||||
it('falls back on a non-numeric string', () => {
|
||||
expect(envInt('abc', 42)).toBe(42);
|
||||
});
|
||||
it('parses a plain integer string', () => {
|
||||
expect(envInt('300', 42)).toBe(300);
|
||||
});
|
||||
it('parses a negative integer', () => {
|
||||
expect(envInt('-5', 42)).toBe(-5);
|
||||
});
|
||||
it('parses a large integer', () => {
|
||||
expect(envInt('1073741824', 42)).toBe(1073741824);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Parse an integer-ish environment variable with a default fallback.
|
||||
*
|
||||
* Used to read PROPERTY_SEED / PROPERTY_NUM_RUNS in the generative property
|
||||
* suites. An unset/empty/non-numeric value falls back to `dflt`, but an explicit
|
||||
* `"0"` is honored (a valid fast-check seed) — `Number(x) || dflt` would wrongly
|
||||
* swallow 0. See flat-roundtrip.property.test.ts / nested-roundtrip.property.test.ts.
|
||||
*/
|
||||
export const envInt = (v: string | undefined, dflt: number): number =>
|
||||
v !== undefined && v !== '' && Number.isFinite(Number(v)) ? Number(v) : dflt;
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
coveredTypes,
|
||||
KNOWN_UNCOVERED,
|
||||
} from './node-generators.js';
|
||||
import { envInt } from './env-int.js';
|
||||
|
||||
// ── Attribute-value coverage allowlist ──────────────────────────────────────
|
||||
// The node/mark completeness contract guarantees every TYPE is generated, but
|
||||
@@ -122,8 +123,7 @@ vi.setConfig({ testTimeout: 30000 });
|
||||
// OOMs the worker).
|
||||
// An unset/empty/non-numeric value falls back to the default; an explicit 0 is
|
||||
// honored (a valid fast-check seed) — `Number(x) || default` would swallow it.
|
||||
const envInt = (v: string | undefined, dflt: number): number =>
|
||||
v !== undefined && v !== '' && Number.isFinite(Number(v)) ? Number(v) : dflt;
|
||||
// The parser is shared with the nested suite (env-int.ts) and unit-tested there.
|
||||
const SEED = envInt(process.env.PROPERTY_SEED, 20250705);
|
||||
const NUM_RUNS = envInt(process.env.PROPERTY_NUM_RUNS, 300);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../../src/lib/index.js';
|
||||
import { firstDivergence } from '../roundtrip-helpers.js';
|
||||
import { schema, docArb } from './doc-generator.js';
|
||||
import { envInt } from './env-int.js';
|
||||
|
||||
// Each run does a real convert + jsdom parse; give ample headroom so the suite
|
||||
// is deterministic under parallel worker load (matching the flat sibling suite).
|
||||
@@ -39,8 +40,7 @@ vi.setConfig({ testTimeout: 60000 });
|
||||
// random seed to hunt for deeper counterexamples.
|
||||
// An unset/empty/non-numeric value falls back to the default; an explicit 0 is
|
||||
// honored (a valid fast-check seed) — `Number(x) || default` would swallow it.
|
||||
const envInt = (v: string | undefined, dflt: number): number =>
|
||||
v !== undefined && v !== '' && Number.isFinite(Number(v)) ? Number(v) : dflt;
|
||||
// The parser is shared with the flat suite (env-int.ts) and unit-tested there.
|
||||
const SEED = envInt(process.env.PROPERTY_SEED, 20250705);
|
||||
// The nested walk builds far heavier docs than the flat suite (each P1/P2 run
|
||||
// parses the emitted markdown through jsdom), so keep the run count moderate to
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
|
||||
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #351 regression — DEGENERATE orderedList `start` normalization (DO-1).
|
||||
//
|
||||
// Only an INTEGER >= 2 is a valid explicit start. A degenerate start (0,
|
||||
// negative, fractional, non-number) MUST collapse to the default 1 on export:
|
||||
// emitting the raw value would produce an untokenizable marker (e.g. "-3.",
|
||||
// "2.5.") that `marked` cannot parse, causing the WHOLE orderedList (and its
|
||||
// listItems) to re-import as a PARAGRAPH — structural corruption. This test
|
||||
// pins that each degenerate start:
|
||||
// (1) exports to "1."/"2." markers (default numbering),
|
||||
// (2) re-imports as a VALID `orderedList` (not a paragraph) with `start`
|
||||
// defaulting, structure preserved,
|
||||
// (3) is byte-stable (md1 === md2).
|
||||
//
|
||||
// Mutation check: revert the converter guard to `Number(raw) || 1` and this
|
||||
// test goes RED (a fractional/negative start leaks into the marker).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const makeDoc = (start: unknown) => ({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { type: null, start },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'alpha' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'beta' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('#351 orderedList degenerate start normalization', () => {
|
||||
for (const start of [0, -3, 2.5]) {
|
||||
it(`start=${start} exports as default "1."/"2." markers`, () => {
|
||||
const md = convertProseMirrorToMarkdown(makeDoc(start));
|
||||
expect(md).toContain('1. alpha');
|
||||
expect(md).toContain('2. beta');
|
||||
// The degenerate value must NOT leak into any marker.
|
||||
expect(md).not.toMatch(/-?\d*\.\d+\.\s/); // no fractional marker like "2.5."
|
||||
expect(md).not.toContain('-3.');
|
||||
});
|
||||
|
||||
it(`start=${start} re-imports as a valid orderedList (not a paragraph)`, async () => {
|
||||
const md = convertProseMirrorToMarkdown(makeDoc(start));
|
||||
const doc = (await markdownToProseMirror(md)) as any;
|
||||
const top = doc.content?.[0];
|
||||
expect(top?.type).toBe('orderedList');
|
||||
// start defaults (never the degenerate value); tiptap materializes 1.
|
||||
expect(top?.attrs?.start ?? 1).toBe(1);
|
||||
expect(top?.content?.length).toBe(2);
|
||||
});
|
||||
|
||||
it(`start=${start} is byte-stable across a re-export`, async () => {
|
||||
const md1 = convertProseMirrorToMarkdown(makeDoc(start));
|
||||
const doc = await markdownToProseMirror(md1);
|
||||
const md2 = convertProseMirrorToMarkdown(doc);
|
||||
expect(md2).toBe(md1);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user