fix: stabilize live session UI and skill publishing
Keep message navigation and progress state synchronized, and preserve sequential split-flap count updates with a bounded queue. Publish the Obelisk skill under skills/obelisk for npx skills, sharing the same staging layout between CI and local releases with regression coverage.
This commit is contained in:
@@ -36,11 +36,8 @@ jobs:
|
|||||||
git -C "$SKILL_DIR" remote add origin git@github.com:tommy0103/obelisk-skill.git
|
git -C "$SKILL_DIR" remote add origin git@github.com:tommy0103/obelisk-skill.git
|
||||||
}
|
}
|
||||||
|
|
||||||
# Replace all content with the fresh build
|
# Keep repository metadata, then stage the skill under skills/obelisk.
|
||||||
find "$SKILL_DIR" -mindepth 1 -not -path "$SKILL_DIR/.git*" -delete
|
bash packaging/stage-skill-repo.sh "$SKILL_DIR"
|
||||||
cp -R dist/obelisk-skill/* "$SKILL_DIR/"
|
|
||||||
cp packaging/skill-README.md "$SKILL_DIR/README.md"
|
|
||||||
cp packaging/skill-LICENSE "$SKILL_DIR/LICENSE"
|
|
||||||
|
|
||||||
cd "$SKILL_DIR"
|
cd "$SKILL_DIR"
|
||||||
git add -A
|
git add -A
|
||||||
|
|||||||
@@ -182,6 +182,9 @@ It starts at `references/recap/overview.md` and proceeds card-by-card:
|
|||||||
- `dist/obelisk-skill/` is produced by `npm run build:skill`. It is the
|
- `dist/obelisk-skill/` is produced by `npm run build:skill`. It is the
|
||||||
install-ready skill artifact: readable plain JavaScript under `scripts/`,
|
install-ready skill artifact: readable plain JavaScript under `scripts/`,
|
||||||
`SKILL.md`, references, and the skill package metadata.
|
`SKILL.md`, references, and the skill package metadata.
|
||||||
|
- Skill publishing stages that artifact at `skills/obelisk/` in the
|
||||||
|
`obelisk-skill` repository; only `README.md` and `LICENSE` remain at the
|
||||||
|
repository root for `npx skills` discovery.
|
||||||
|
|
||||||
Both directories are generated and should not be edited by hand. The Electron
|
Both directories are generated and should not be edited by hand. The Electron
|
||||||
app imports `packages/core/src/` directly so electron-vite can bundle Core.
|
app imports `packages/core/src/` directly so electron-vite can bundle Core.
|
||||||
|
|||||||
@@ -2,6 +2,52 @@ function normalizeFlapValue(value) {
|
|||||||
return String(value ?? '');
|
return String(value ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_QUEUED_FLAPS = 4;
|
||||||
|
const MAX_CONSECUTIVE_STEPS = 4;
|
||||||
|
|
||||||
|
function normalizeQueue(queued) {
|
||||||
|
if (Array.isArray(queued)) return queued;
|
||||||
|
return queued === null || queued === undefined ? [] : [normalizeFlapValue(queued)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function numericDirection(from, to) {
|
||||||
|
if (!/^(0|[1-9]\d*)$/.test(from) || !/^(0|[1-9]\d*)$/.test(to)) return null;
|
||||||
|
const fromNumber = Number(from);
|
||||||
|
const toNumber = Number(to);
|
||||||
|
if (!Number.isSafeInteger(fromNumber) || !Number.isSafeInteger(toNumber)) return null;
|
||||||
|
return Math.sign(toNumber - fromNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetsBetween(from, to) {
|
||||||
|
const direction = numericDirection(from, to);
|
||||||
|
if (direction === null) return [to];
|
||||||
|
if (direction === 0) return [];
|
||||||
|
const distance = Math.abs(Number(to) - Number(from));
|
||||||
|
if (distance > MAX_CONSECUTIVE_STEPS) return [to];
|
||||||
|
return Array.from(
|
||||||
|
{ length: distance },
|
||||||
|
(_, index) => String(Number(from) + direction * (index + 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedQueue(targets) {
|
||||||
|
if (targets.length <= MAX_QUEUED_FLAPS) return targets;
|
||||||
|
return [...targets.slice(0, MAX_QUEUED_FLAPS - 1), targets.at(-1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function startFlap(state, targets) {
|
||||||
|
const [to, ...queued] = targets;
|
||||||
|
if (to === undefined) return state;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
from: state.settled,
|
||||||
|
to,
|
||||||
|
animating: true,
|
||||||
|
queued: boundedQueue(queued),
|
||||||
|
version: state.version + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createFlapState(value) {
|
export function createFlapState(value) {
|
||||||
const settled = normalizeFlapValue(value);
|
const settled = normalizeFlapValue(value);
|
||||||
return {
|
return {
|
||||||
@@ -9,7 +55,7 @@ export function createFlapState(value) {
|
|||||||
from: settled,
|
from: settled,
|
||||||
to: settled,
|
to: settled,
|
||||||
animating: false,
|
animating: false,
|
||||||
queued: null,
|
queued: [],
|
||||||
version: 0,
|
version: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -18,33 +64,38 @@ export function requestFlap(state, value, { reducedMotion = false } = {}) {
|
|||||||
const next = normalizeFlapValue(value);
|
const next = normalizeFlapValue(value);
|
||||||
if (reducedMotion) return createFlapState(next);
|
if (reducedMotion) return createFlapState(next);
|
||||||
if (state.animating) {
|
if (state.animating) {
|
||||||
if (next === state.to) return { ...state, queued: null };
|
const queued = normalizeQueue(state.queued);
|
||||||
return { ...state, queued: next };
|
if (next === state.to) return { ...state, queued: [] };
|
||||||
|
const queuedIndex = queued.indexOf(next);
|
||||||
|
if (queuedIndex >= 0) return { ...state, queued: queued.slice(0, queuedIndex + 1) };
|
||||||
|
|
||||||
|
const tail = queued.at(-1) ?? state.to;
|
||||||
|
const activeDirection = numericDirection(state.from, tail);
|
||||||
|
const incomingDirection = numericDirection(tail, next);
|
||||||
|
const additions = targetsBetween(tail, next);
|
||||||
|
if (activeDirection !== null && incomingDirection !== null
|
||||||
|
&& (activeDirection === 0 || incomingDirection === activeDirection)) {
|
||||||
|
return { ...state, queued: boundedQueue([...queued, ...additions]) };
|
||||||
|
}
|
||||||
|
return { ...state, queued: boundedQueue(targetsBetween(state.to, next)) };
|
||||||
}
|
}
|
||||||
if (next === state.settled) return state;
|
if (next === state.settled) return state;
|
||||||
return {
|
return startFlap(state, targetsBetween(state.settled, next));
|
||||||
...state,
|
|
||||||
from: state.settled,
|
|
||||||
to: next,
|
|
||||||
animating: true,
|
|
||||||
queued: null,
|
|
||||||
version: state.version + 1,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function finishFlap(state) {
|
export function finishFlap(state) {
|
||||||
if (!state.animating) return state;
|
if (!state.animating) return state;
|
||||||
const settled = state.to;
|
const settled = state.to;
|
||||||
const queued = state.queued;
|
const queued = normalizeQueue(state.queued);
|
||||||
const stable = {
|
const stable = {
|
||||||
...state,
|
...state,
|
||||||
settled,
|
settled,
|
||||||
from: settled,
|
from: settled,
|
||||||
to: settled,
|
to: settled,
|
||||||
animating: false,
|
animating: false,
|
||||||
queued: null,
|
queued: [],
|
||||||
};
|
};
|
||||||
return queued !== null && queued !== settled ? requestFlap(stable, queued) : stable;
|
return queued.length ? startFlap(stable, queued) : stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function flapSlots(fromValue, toValue) {
|
export function flapSlots(fromValue, toValue) {
|
||||||
|
|||||||
@@ -222,6 +222,11 @@ function onScroll(event) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setMessagePosition(index, total) {
|
||||||
|
currentMsgIdx.value = index;
|
||||||
|
progressPct.value = total <= 1 ? 100 : Math.round((index / (total - 1)) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
function updateScrollProgress() {
|
function updateScrollProgress() {
|
||||||
if (!wrapRef.value || !detailRef.value) return;
|
if (!wrapRef.value || !detailRef.value) return;
|
||||||
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card');
|
const msgs = detailRef.value.querySelectorAll('.msg, .wf-card, .skill-card');
|
||||||
@@ -235,9 +240,7 @@ function updateScrollProgress() {
|
|||||||
const navHeight = 52;
|
const navHeight = 52;
|
||||||
const bottomLine = el.getBoundingClientRect().bottom - navHeight;
|
const bottomLine = el.getBoundingClientRect().bottom - navHeight;
|
||||||
const bottomMsgIdx = findLastMessageAtOrAbove(msgs, bottomLine);
|
const bottomMsgIdx = findLastMessageAtOrAbove(msgs, bottomLine);
|
||||||
currentMsgIdx.value = bottomMsgIdx;
|
setMessagePosition(bottomMsgIdx, msgs.length);
|
||||||
const pct = msgs.length <= 1 ? 100 : Math.round((bottomMsgIdx / (msgs.length - 1)) * 100);
|
|
||||||
progressPct.value = pct;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function navTo(target) {
|
function navTo(target) {
|
||||||
@@ -250,7 +253,7 @@ function navTo(target) {
|
|||||||
else if (target === 'prev') idx = Math.max(0, currentMsgIdx.value - 1);
|
else if (target === 'prev') idx = Math.max(0, currentMsgIdx.value - 1);
|
||||||
else if (target === 'next') idx = Math.min(msgs.length - 1, currentMsgIdx.value + 1);
|
else if (target === 'next') idx = Math.min(msgs.length - 1, currentMsgIdx.value + 1);
|
||||||
else return;
|
else return;
|
||||||
currentMsgIdx.value = idx;
|
setMessagePosition(idx, msgs.length);
|
||||||
navLock = true;
|
navLock = true;
|
||||||
const navHeight = 52;
|
const navHeight = 52;
|
||||||
const el = wrapRef.value;
|
const el = wrapRef.value;
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SKILL_DIR="dist/obelisk-skill"
|
SKILL_ARTIFACT="dist/obelisk-skill"
|
||||||
|
SKILL_REPO="dist/obelisk-skill-repo"
|
||||||
REMOTE="git@github.com:tommy0103/obelisk-skill.git"
|
REMOTE="git@github.com:tommy0103/obelisk-skill.git"
|
||||||
|
|
||||||
if [ ! -d "$SKILL_DIR/scripts" ]; then
|
if [ ! -d "$SKILL_ARTIFACT/scripts" ]; then
|
||||||
echo "Error: run 'npm run build:skill' first" >&2
|
echo "Error: run 'npm run build:skill' first" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cp packaging/skill-README.md "$SKILL_DIR/README.md"
|
bash packaging/stage-skill-repo.sh "$SKILL_REPO" "$SKILL_ARTIFACT"
|
||||||
cp packaging/skill-LICENSE "$SKILL_DIR/LICENSE"
|
|
||||||
|
|
||||||
cd "$SKILL_DIR"
|
cd "$SKILL_REPO"
|
||||||
rm -rf .git
|
rm -rf .git
|
||||||
git init
|
git init
|
||||||
git remote add origin "$REMOTE"
|
git remote add origin "$REMOTE"
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
TARGET_DIR="${1:-}"
|
||||||
|
ARTIFACT_DIR="${2:-$ROOT_DIR/dist/obelisk-skill}"
|
||||||
|
|
||||||
|
if [ -z "$TARGET_DIR" ]; then
|
||||||
|
echo "Usage: packaging/stage-skill-repo.sh <target-repo> [skill-artifact]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$TARGET_DIR" = "/" ] || [ "$TARGET_DIR" = "." ] || [ "$TARGET_DIR" = "$ROOT_DIR" ]; then
|
||||||
|
echo "Error: refusing to replace unsafe target directory: $TARGET_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for required in SKILL.md package.json references scripts; do
|
||||||
|
if [ ! -e "$ARTIFACT_DIR/$required" ]; then
|
||||||
|
echo "Error: skill artifact missing $required at $ARTIFACT_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p "$TARGET_DIR"
|
||||||
|
find "$TARGET_DIR" -mindepth 1 \
|
||||||
|
! -path "$TARGET_DIR/.git" \
|
||||||
|
! -path "$TARGET_DIR/.git/*" \
|
||||||
|
-delete
|
||||||
|
|
||||||
|
mkdir -p "$TARGET_DIR/skills/obelisk"
|
||||||
|
cp -R "$ARTIFACT_DIR"/. "$TARGET_DIR/skills/obelisk/"
|
||||||
|
cp "$ROOT_DIR/packaging/skill-README.md" "$TARGET_DIR/README.md"
|
||||||
|
cp "$ROOT_DIR/packaging/skill-LICENSE" "$TARGET_DIR/LICENSE"
|
||||||
@@ -24,11 +24,12 @@ test('flap state queues rapid updates without interrupting the active flap', ()
|
|||||||
|
|
||||||
assert.equal(state.from, '822');
|
assert.equal(state.from, '822');
|
||||||
assert.equal(state.to, '823');
|
assert.equal(state.to, '823');
|
||||||
assert.equal(state.queued, '824');
|
assert.deepEqual(state.queued, ['824']);
|
||||||
|
|
||||||
state = finishFlap(state);
|
state = finishFlap(state);
|
||||||
assert.equal(state.from, '823');
|
assert.equal(state.from, '823');
|
||||||
assert.equal(state.to, '824');
|
assert.equal(state.to, '824');
|
||||||
|
assert.deepEqual(state.queued, []);
|
||||||
assert.equal(state.animating, true);
|
assert.equal(state.animating, true);
|
||||||
|
|
||||||
state = finishFlap(state);
|
state = finishFlap(state);
|
||||||
@@ -36,6 +37,59 @@ test('flap state queues rapid updates without interrupting the active flap', ()
|
|||||||
assert.equal(state.animating, false);
|
assert.equal(state.animating, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('flap queue preserves every rapid numeric step in order', () => {
|
||||||
|
let state = createFlapState(907);
|
||||||
|
state = requestFlap(state, 908);
|
||||||
|
state = requestFlap(state, 909);
|
||||||
|
state = requestFlap(state, 910);
|
||||||
|
|
||||||
|
assert.equal(state.to, '908');
|
||||||
|
assert.deepEqual(state.queued, ['909', '910']);
|
||||||
|
|
||||||
|
state = finishFlap(state);
|
||||||
|
assert.equal(state.from, '908');
|
||||||
|
assert.equal(state.to, '909');
|
||||||
|
assert.deepEqual(state.queued, ['910']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a small direct numeric jump expands into consecutive flap targets', () => {
|
||||||
|
const state = requestFlap(createFlapState(908), 910);
|
||||||
|
|
||||||
|
assert.equal(state.from, '908');
|
||||||
|
assert.equal(state.to, '909');
|
||||||
|
assert.deepEqual(state.queued, ['910']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the flap queue stays bounded while retaining the latest target', () => {
|
||||||
|
let state = createFlapState(0);
|
||||||
|
for (let value = 1; value <= 10; value++) state = requestFlap(state, value);
|
||||||
|
|
||||||
|
assert.ok(state.queued.length <= 4);
|
||||||
|
assert.equal(state.queued.at(-1), '10');
|
||||||
|
|
||||||
|
while (state.animating) state = finishFlap(state);
|
||||||
|
assert.equal(state.settled, '10');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('large numeric jumps go directly to the latest value', () => {
|
||||||
|
const state = requestFlap(createFlapState(908), 1000);
|
||||||
|
|
||||||
|
assert.equal(state.to, '1000');
|
||||||
|
assert.deepEqual(state.queued, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reversing update discards stale forward targets', () => {
|
||||||
|
let state = createFlapState(907);
|
||||||
|
state = requestFlap(state, 908);
|
||||||
|
state = requestFlap(state, 909);
|
||||||
|
state = requestFlap(state, 907);
|
||||||
|
|
||||||
|
assert.deepEqual(state.queued, ['907']);
|
||||||
|
state = finishFlap(state);
|
||||||
|
assert.equal(state.from, '908');
|
||||||
|
assert.equal(state.to, '907');
|
||||||
|
});
|
||||||
|
|
||||||
test('the latest request clears an older queued value when it matches the active target', () => {
|
test('the latest request clears an older queued value when it matches the active target', () => {
|
||||||
let state = createFlapState(822);
|
let state = createFlapState(822);
|
||||||
state = requestFlap(state, 823);
|
state = requestFlap(state, 823);
|
||||||
@@ -43,7 +97,7 @@ test('the latest request clears an older queued value when it matches the active
|
|||||||
state = requestFlap(state, 823);
|
state = requestFlap(state, 823);
|
||||||
|
|
||||||
assert.equal(state.to, '823');
|
assert.equal(state.to, '823');
|
||||||
assert.equal(state.queued, null);
|
assert.deepEqual(state.queued, []);
|
||||||
state = finishFlap(state);
|
state = finishFlap(state);
|
||||||
assert.equal(state.settled, '823');
|
assert.equal(state.settled, '823');
|
||||||
assert.equal(state.animating, false);
|
assert.equal(state.animating, false);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const stageScript = join(repoRoot, 'packaging', 'stage-skill-repo.sh');
|
||||||
|
|
||||||
|
test('skill release staging produces the npx skills repository layout', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'obelisk-skill-release-'));
|
||||||
|
const artifact = join(root, 'artifact');
|
||||||
|
const target = join(root, 'repo');
|
||||||
|
try {
|
||||||
|
mkdirSync(join(artifact, 'references'), { recursive: true });
|
||||||
|
mkdirSync(join(artifact, 'scripts'), { recursive: true });
|
||||||
|
mkdirSync(join(target, '.git'), { recursive: true });
|
||||||
|
writeFileSync(join(artifact, 'SKILL.md'), '---\nname: obelisk\ndescription: test\n---\n');
|
||||||
|
writeFileSync(join(artifact, 'package.json'), '{"type":"module"}\n');
|
||||||
|
writeFileSync(join(artifact, 'references', 'api-reference.md'), '# API\n');
|
||||||
|
writeFileSync(join(artifact, 'scripts', 'runtime.js'), 'export {};\n');
|
||||||
|
writeFileSync(join(target, '.git', 'keep'), 'preserved\n');
|
||||||
|
writeFileSync(join(target, 'stale.txt'), 'remove me\n');
|
||||||
|
|
||||||
|
const result = spawnSync('bash', [stageScript, target, artifact], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||||
|
|
||||||
|
assert.deepEqual(readdirSync(target).sort(), ['.git', 'LICENSE', 'README.md', 'skills']);
|
||||||
|
assert.deepEqual(readdirSync(join(target, 'skills')).sort(), ['obelisk']);
|
||||||
|
for (const relativePath of [
|
||||||
|
'SKILL.md',
|
||||||
|
'package.json',
|
||||||
|
'references/api-reference.md',
|
||||||
|
'scripts/runtime.js',
|
||||||
|
]) {
|
||||||
|
assert.equal(existsSync(join(target, 'skills', 'obelisk', relativePath)), true);
|
||||||
|
}
|
||||||
|
assert.equal(existsSync(join(target, '.git', 'keep')), true);
|
||||||
|
assert.equal(existsSync(join(target, 'stale.txt')), false);
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CI and local publish use the same skill repository staging step', () => {
|
||||||
|
const workflow = readFileSync(join(repoRoot, '.github', 'workflows', 'publish-skill.yml'), 'utf8');
|
||||||
|
const localPublish = readFileSync(join(repoRoot, 'packaging', 'publish-skill.sh'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(workflow, /packaging\/stage-skill-repo\.sh/);
|
||||||
|
assert.match(localPublish, /packaging\/stage-skill-repo\.sh/);
|
||||||
|
});
|
||||||
@@ -186,3 +186,15 @@ test('live totals and scroll position remain isolated across interleaved updates
|
|||||||
assert.match(updateScrollProgress, /currentMsgIdx\.value\s*=/);
|
assert.match(updateScrollProgress, /currentMsgIdx\.value\s*=/);
|
||||||
assert.doesNotMatch(updateScrollProgress, /totalMsgs\.value\s*=/);
|
assert.doesNotMatch(updateScrollProgress, /totalMsgs\.value\s*=/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('message navigation keeps the top progress bar aligned with the current position', () => {
|
||||||
|
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
|
||||||
|
const setMessagePosition = functionSource(source, 'setMessagePosition');
|
||||||
|
const updateScrollProgress = functionSource(source, 'updateScrollProgress');
|
||||||
|
const navTo = functionSource(source, 'navTo');
|
||||||
|
|
||||||
|
assert.match(setMessagePosition, /currentMsgIdx\.value\s*=\s*index/);
|
||||||
|
assert.match(setMessagePosition, /progressPct\.value\s*=/);
|
||||||
|
assert.match(updateScrollProgress, /setMessagePosition\(bottomMsgIdx,\s*msgs\.length\)/);
|
||||||
|
assert.match(navTo, /setMessagePosition\(idx,\s*msgs\.length\)/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user