Files
hermes-webui/tests/test_issue6066_workspace_sort.py

375 lines
18 KiB
Python

"""Focused regression coverage for workspace sorting and birthtime metadata."""
import hashlib
import json
import os
import subprocess
from pathlib import Path
import pytest
from api import workspace as workspace_api
ROOT = Path(__file__).resolve().parents[1]
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
def test_list_dir_emits_birthtime_ns(tmp_path):
(tmp_path / "file.txt").write_text("x", encoding="utf-8")
(tmp_path / "folder").mkdir()
entries = workspace_api.list_dir(tmp_path, ".")
assert {entry["type"] for entry in entries} == {"file", "dir"}
assert all("birthtime_ns" in entry for entry in entries)
assert all(isinstance(entry["birthtime_ns"], (int, type(None))) for entry in entries)
assert {entry["workspace_sort_rank"] for entry in entries} == {1, 2}
def test_list_dir_emits_server_partition_rank_for_special_entries(tmp_path, monkeypatch):
if not hasattr(os, "mkfifo"):
pytest.skip("FIFO creation is unavailable on this platform")
try:
os.mkfifo(tmp_path / "fifo")
(tmp_path / "directory").mkdir()
(tmp_path / "file.txt").write_text("x", encoding="utf-8")
link_target = tmp_path / "file.txt"
(tmp_path / "link").symlink_to(link_target)
except (OSError, NotImplementedError) as exc:
pytest.skip(f"special entry creation unavailable: {exc}")
modes = [False]
if workspace_api._DIR_FD_OK:
modes.append(True)
for use_dir_fd in modes:
monkeypatch.setattr(workspace_api, "_DIR_FD_OK", use_dir_fd)
entries = {entry["name"]: entry for entry in workspace_api.list_dir(tmp_path, ".")}
assert entries["link"]["workspace_sort_rank"] == 0
assert entries["directory"]["workspace_sort_rank"] == 1
assert entries["fifo"]["workspace_sort_rank"] == 1
assert entries["file.txt"]["workspace_sort_rank"] == 2
def test_birthtime_ns_platform_matrix(monkeypatch):
birthtime_ns = getattr(workspace_api, "_birthtime_ns", None)
assert birthtime_ns is not None, "_birthtime_ns helper is missing"
class Stat:
pass
modern = Stat()
modern.st_birthtime_ns = 123
assert birthtime_ns(modern) == 123
mac = Stat()
mac.st_birthtime = 1.5
assert birthtime_ns(mac) == 1_500_000_000
windows = Stat()
windows.st_ctime_ns = 456
monkeypatch.setattr("api.workspace.sys.platform", "win32")
assert birthtime_ns(windows) == 456
linux = Stat()
monkeypatch.setattr("api.workspace.sys.platform", "linux")
assert birthtime_ns(linux) is None
def test_escape_symlink_birthtime_is_link_local(tmp_path):
outside = tmp_path / "outside.txt"
outside.write_text("outside", encoding="utf-8")
workspace = tmp_path / "workspace"
workspace.mkdir()
link = workspace / "escape.txt"
try:
link.symlink_to(outside)
except (OSError, NotImplementedError) as exc:
pytest.skip(f"symlink unavailable: {exc}")
entry = next(item for item in workspace_api.list_dir(workspace, ".") if item["name"] == "escape.txt")
assert entry["target_outside_workspace"] is True
assert entry["workspace_sort_rank"] == 0
birthtime_ns = getattr(workspace_api, "_birthtime_ns", None)
assert birthtime_ns is not None, "_birthtime_ns helper is missing"
assert entry["birthtime_ns"] == birthtime_ns(link.lstat())
assert "target" not in entry and "size" not in entry
def test_dir_signature_unchanged_by_birthtime(tmp_path):
(tmp_path / "same.txt").write_text("same", encoding="utf-8")
entries = workspace_api.list_dir(tmp_path, ".")
expected = hashlib.sha256(
json.dumps(
[
{
"name": entry.get("name"),
"path": entry.get("path"),
"type": entry.get("type"),
"is_dir": entry.get("is_dir"),
"size": entry.get("size"),
"mtime_ns": entry.get("mtime_ns"),
"target": entry.get("target"),
"target_outside_workspace": entry.get("target_outside_workspace"),
}
for entry in entries
],
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
).encode("utf-8")
).hexdigest()
assert workspace_api.dir_signature(tmp_path, ".", entries) == expected
def _run_sort_harness(body):
start = UI_JS.index("const WORKSPACE_SORT_KEYS=")
end = UI_JS.index("// ── Workspace preferences kebab menu", start)
source = UI_JS[start:end]
script = """
global.S={session:{workspace:'/test'},showHiddenWorkspaceFiles:true,workspaceSortKey:'name-asc'};
global.localStorage={getItem(key){return key==='hermes-workspace-show-hidden-files'?'1':null},setItem(){}};
global.$=()=>null;
function _visibleWorkspaceEntries(entries){return S.showHiddenWorkspaceFiles?entries:entries.filter(()=>true)}
""" + source + "\n" + body
return subprocess.run(["node", "-e", script], capture_output=True, text=True, check=False)
def test_sort_modified_desc_within_rank():
result = _run_sort_harness("""
S.workspaceSortKey='modified-desc';
const input=[{name:'file-old',type:'file',workspace_sort_rank:2,mtime_ns:1},{name:'dir',type:'dir',workspace_sort_rank:1,mtime_ns:2},{name:'file-new',type:'file',workspace_sort_rank:2,mtime_ns:3},{name:'link',type:'symlink',workspace_sort_rank:0,mtime_ns:0}];
const out=_workspaceEntriesForRender(input).map(x=>x.name);
if(JSON.stringify(out)!=='[\"link\",\"dir\",\"file-new\",\"file-old\"]')process.exit(1);
""")
assert result.returncode == 0, result.stderr
def test_sort_name_desc_within_rank():
result = _run_sort_harness("""
S.workspaceSortKey='name-desc';
const input=[{name:'a',type:'file',workspace_sort_rank:2},{name:'dir',type:'dir',workspace_sort_rank:1},{name:'b',type:'file',workspace_sort_rank:2},{name:'link',type:'symlink',workspace_sort_rank:0}];
const out=_workspaceEntriesForRender(input).map(x=>x.name);
if(JSON.stringify(out)!=='[\"link\",\"dir\",\"b\",\"a\"]')process.exit(1);
""")
assert result.returncode == 0, result.stderr
def test_name_asc_is_identity():
result = _run_sort_harness("""
const input=[{name:'b',type:'file'},{name:'a',type:'file'}];
const out=_workspaceEntriesForRender(input);
if(out!==input||out[0]!==input[0])process.exit(1);
""")
assert result.returncode == 0, result.stderr
def test_rank_grouping_holds_all_keys():
result = _run_sort_harness("""
const input=[{name:'file',type:'file',workspace_sort_rank:2,mtime_ns:3},{name:'fifo',type:'file',workspace_sort_rank:1,mtime_ns:4},{name:'dir',type:'dir',workspace_sort_rank:1,mtime_ns:2},{name:'link',type:'symlink',workspace_sort_rank:0,mtime_ns:1}];
for(const key of ['name-desc','created-desc','modified-desc']){S.workspaceSortKey=key;S._workspaceBirthtimeSeen=true;const ranks=_workspaceEntriesForRender(input).map(_workspaceEntryRank);if(JSON.stringify(ranks)!=='[0,1,1,2]')process.exit(1)}
""")
assert result.returncode == 0, result.stderr
def test_unknown_workspace_rank_uses_non_regular_partition():
result = _run_sort_harness("""
if(_workspaceEntryRank({type:'file'})!==1||_workspaceEntryRank({type:'file',workspace_sort_rank:'2garbage'})!==1||_workspaceEntryRank({type:'file',workspace_sort_rank:2.5})!==1||_workspaceEntryRank({type:'file',workspace_sort_rank:'bogus'})!==1||_workspaceEntryRank({type:'file',workspace_sort_rank:3})!==1)process.exit(1);
""")
assert result.returncode == 0, result.stderr
def test_no_workspace_render_clears_birthtime_support():
start = UI_JS.index("function renderFileTree(){")
end = UI_JS.index("let _wsActiveDragPath=", start)
render_source = UI_JS[start:end]
sort_start = UI_JS.index("const WORKSPACE_SORT_KEYS=")
sort_end = UI_JS.index("// ── Workspace preferences kebab menu", sort_start)
sort_source = UI_JS[sort_start:sort_end]
script = """
const box={scrollTop:0,innerHTML:'',style:{}};
const empty={textContent:'',style:{}};
global.S={session:null,entries:[],currentDir:'.',_dirCache:{},showHiddenWorkspaceFiles:true,workspaceSortKey:'created-desc',_workspaceBirthtimeSeen:true,_workspaceBirthtimeWorkspace:'/old'};
global.localStorage={getItem(){return null},setItem(){}};
global.$=id=>id==='fileTree'?box:id==='wsEmptyState'?empty:null;
global.t=()=>'';
var _workspacePrefsMenu=null;
""" + sort_source + render_source + """
renderFileTree();
if(S._workspaceBirthtimeSeen!==false||S._workspaceBirthtimeWorkspace!=='')process.exit(1);
"""
result = subprocess.run(["node", "-e", script], capture_output=True, text=True, check=False)
assert result.returncode == 0, result.stderr
def test_session_teardown_disables_created_sort():
result = _run_sort_harness("""
S._workspaceBirthtimeSeen=true;
if(_workspaceCreatedSortAvailable()!==true)process.exit(1);
S.session=null;
if(_workspaceCreatedSortAvailable()!==false)process.exit(1);
""")
assert result.returncode == 0, result.stderr
def test_missing_timestamps_sort_last():
result = _run_sort_harness("""
S.workspaceSortKey='created-desc';S._workspaceBirthtimeSeen=true;
const input=[{name:'missing',type:'file',workspace_sort_rank:2,birthtime_ns:null},{name:'zero',type:'file',workspace_sort_rank:2,birthtime_ns:0},{name:'new',type:'file',workspace_sort_rank:2,birthtime_ns:2},{name:'undefined',type:'file',workspace_sort_rank:2}];
const out=_workspaceEntriesForRender(input).map(x=>x.name);
if(JSON.stringify(out)!=='[\"new\",\"zero\",\"missing\",\"undefined\"]')process.exit(1);
""")
assert result.returncode == 0, result.stderr
def test_large_nanosecond_strings_sort_exactly():
result = _run_sort_harness("""
S.workspaceSortKey='modified-desc';
const input=[{name:'older',type:'file',workspace_sort_rank:2,mtime_ns:'1752598800000000000'},{name:'newer',type:'file',workspace_sort_rank:2,mtime_ns:'1752598800000000001'}];
const out=_workspaceEntriesForRender(input).map(x=>x.name);
if(JSON.stringify(out)!=='[\"newer\",\"older\"]')process.exit(1);
""")
assert result.returncode == 0, result.stderr
def test_signed_nanosecond_values_sort_exactly():
result = _run_sort_harness("""
S.workspaceSortKey='modified-desc';
const input=[{name:'negative-string',type:'file',workspace_sort_rank:2,mtime_ns:'-2'},{name:'zero',type:'file',workspace_sort_rank:2,mtime_ns:0},{name:'negative-number',type:'file',workspace_sort_rank:2,mtime_ns:-1}];
const out=_workspaceEntriesForRender(input).map(x=>x.name);
if(JSON.stringify(out)!=='[\"zero\",\"negative-number\",\"negative-string\"]')process.exit(1);
""")
assert result.returncode == 0, result.stderr
def test_signed_zero_and_leading_zero_strings_compare_equal():
result = _run_sort_harness("""
S.workspaceSortKey='modified-desc';
const input=[{name:'zero',type:'file',workspace_sort_rank:2,mtime_ns:'0'},{name:'negative-zero',type:'file',workspace_sort_rank:2,mtime_ns:'-0'},{name:'positive-zero',type:'file',workspace_sort_rank:2,mtime_ns:'+0'},{name:'leading-zero',type:'file',workspace_sort_rank:2,mtime_ns:'000'},{name:'negative-leading-zero',type:'file',workspace_sort_rank:2,mtime_ns:'-00'}];
if(['0','-0','+0','000','-00'].some(value=>_workspaceEntryTimestampKey({mtime_ns:value},'mtime_ns')!=='0'))process.exit(1);
const out=_workspaceEntriesForRender(input).map(x=>x.name);
if(JSON.stringify(out)!=='[\"zero\",\"negative-zero\",\"positive-zero\",\"leading-zero\",\"negative-leading-zero\"]')process.exit(1);
""")
assert result.returncode == 0, result.stderr
@pytest.mark.parametrize("value", ["", " ", "null", "{\"k\":1}", "created"])
def test_stored_sort_key_normalization(value):
result = _run_sort_harness(f"""
if(!_normalizeWorkspaceSortKey({json.dumps(value)})||!WORKSPACE_SORT_KEYS.includes(_normalizeWorkspaceSortKey({json.dumps(value)})))process.exit(1);
""")
assert result.returncode == 0, result.stderr
def test_prefs_indicators_or_logic():
result = _run_sort_harness("""
const mk=()=>({hidden:true,attrs:{},setAttribute(k,v){this.attrs[k]=String(v);},removeAttribute(k){delete this.attrs[k];}});
const ind=mk(),dot=mk();
S.showHiddenWorkspaceFiles=false;S.workspaceSortKey='name-asc';
_syncWorkspacePrefsIndicators(ind,dot);
if(ind.hidden!==true||dot.hidden!==true)process.exit(1);
S.workspaceSortKey='modified-desc';
_syncWorkspacePrefsIndicators(ind,dot);
if(ind.hidden!==true||dot.hidden!==false)process.exit(1);
S.showHiddenWorkspaceFiles=true;S.workspaceSortKey='name-asc';
_syncWorkspacePrefsIndicators(ind,dot);
if(ind.hidden!==false||dot.hidden!==false)process.exit(1);
""")
assert result.returncode == 0, result.stderr
def test_created_pref_survives_unavailable():
result = _run_sort_harness("""
const mk=()=>({hidden:true,attrs:{},setAttribute(k,v){this.attrs[k]=String(v);},removeAttribute(k){delete this.attrs[k];}});
const ind=mk(),dot=mk();
S.showHiddenWorkspaceFiles=false;S._workspaceBirthtimeSeen=false;S.workspaceSortKey='created-desc';
const input=[{name:'b',type:'file',workspace_sort_rank:2,birthtime_ns:null},{name:'a',type:'file',workspace_sort_rank:2,birthtime_ns:null}];
_noteWorkspaceBirthtimeSupport(input);
const out=_workspaceEntriesForRender(input).map(x=>x.name);
_syncWorkspacePrefsIndicators(ind,dot);
if(S._workspaceBirthtimeSeen!==false||_workspaceCreatedSortAvailable()!==false||_effectiveWorkspaceSortKey()!=='name-asc'||S.workspaceSortKey!=='created-desc'||JSON.stringify(out)!=='[\"b\",\"a\"]'||ind.hidden!==true||dot.hidden!==true)process.exit(1);
""")
assert result.returncode == 0, result.stderr
def test_unavailable_created_pref_uses_effective_menu_radio_state():
result = _run_sort_harness("""
const mkRow=value=>{
const input={value,checked:false,disabled:false};
return {_input:input,attrs:{},classList:{toggle(){}},setAttribute(k,v){this.attrs[k]=String(v);},querySelector(sel){return sel==='input[name="workspaceSortKey"]'?input:null;}};
};
const nameRow=mkRow('name-asc');
const createdRow=mkRow('created-desc');
_workspacePrefsMenu={querySelectorAll(sel){return sel==='.workspace-prefs-item--radio'?[nameRow,createdRow]:[];}};
S._workspaceBirthtimeSeen=false;
S.workspaceSortKey='created-desc';
_syncWorkspaceSortMenuState();
if(nameRow._input.checked!==true||nameRow.attrs['aria-checked']!=='true'||createdRow._input.checked!==false||createdRow._input.disabled!==true||createdRow.attrs['aria-checked']!=='false'||createdRow.attrs['aria-disabled']!=='true')process.exit(1);
""")
assert result.returncode == 0, result.stderr
def test_birthtime_support_scope_resets_on_workspace_change():
result = _run_sort_harness("""
const input={value:'created-desc',checked:true,disabled:false};
const row={attrs:{},classList:{toggle(){}},setAttribute(k,v){this.attrs[k]=String(v);},querySelector(sel){return sel==='input[name="workspaceSortKey"]'?input:null;}};
_workspacePrefsMenu={querySelectorAll(sel){return sel==='.workspace-prefs-item--radio'?[row]:[];}};
S.session={workspace:'/one'};
_syncWorkspaceBirthtimeSupportScope(S.session.workspace);
_noteWorkspaceBirthtimeSupport([{name:'seen',type:'file',workspace_sort_rank:2,birthtime_ns:'1752598800000000001'}]);
if(_workspaceCreatedSortAvailable()!==true)process.exit(1);
S.session={workspace:'/two'};
_syncWorkspaceBirthtimeSupportScope(S.session.workspace);
if(S._workspaceBirthtimeSeen!==false||_workspaceCreatedSortAvailable()!==false||input.disabled!==true||row.attrs['aria-disabled']!=='true')process.exit(1);
""")
assert result.returncode == 0, result.stderr
def test_birthtime_support_scope_reconciles_open_menu_metadata():
result = _run_sort_harness("""
global.t=key=>key==='workspace_sort_created_unavailable'?'Creation unavailable':key;
global.document={createElement(){return {className:'',textContent:'',remove(){this.removed=true;},};}};
const meta={textContent:'Creation unavailable',remove(){this.removed=true;}};
const copy={children:[meta],appendChild(node){this.children.push(node);}};
const input={value:'created-desc',checked:false,disabled:true};
const row={attrs:{},classList:{toggle(){}},setAttribute(k,v){this.attrs[k]=String(v);},querySelector(sel){
if(sel==='input[name="workspaceSortKey"]')return input;
if(sel==='.workspace-prefs-copy')return copy;
if(sel==='.workspace-prefs-meta')return copy.children.find(node=>!node.removed)||null;
return null;
}};
_workspacePrefsMenu={querySelectorAll(sel){return sel==='.workspace-prefs-item--radio'?[row]:[];}};
S._workspaceBirthtimeSeen=false;
_syncWorkspaceSortMenuState();
if(!row.querySelector('.workspace-prefs-meta')||row.querySelector('.workspace-prefs-meta').textContent!=='Creation unavailable')process.exit(1);
S._workspaceBirthtimeSeen=true;
_syncWorkspaceSortMenuState();
if(row.querySelector('.workspace-prefs-meta'))process.exit(1);
S._workspaceBirthtimeSeen=false;
_syncWorkspaceSortMenuState();
const recreated=row.querySelector('.workspace-prefs-meta');
if(!recreated||recreated.textContent!=='Creation unavailable')process.exit(1);
""")
assert result.returncode == 0, result.stderr
def test_set_workspace_sort_key_updates_open_menuitemradio_state():
result = _run_sort_harness("""
global.renderFileTree=()=>{};
const rows=['name-asc','modified-desc'].map(value=>{
const input={value,checked:false};
return {_input:input,attrs:{},setAttribute(k,v){this.attrs[k]=String(v);},querySelector(sel){return sel==='input[name="workspaceSortKey"]'?input:null;}};
});
_workspacePrefsMenu={querySelectorAll(sel){return sel==='.workspace-prefs-item--radio'?rows:[];}};
S.workspaceSortKey='name-asc';
setWorkspaceSortKey('modified-desc');
if(rows[0].attrs['aria-checked']!=='false'||rows[1].attrs['aria-checked']!=='true'||rows[0]._input.checked!==false||rows[1]._input.checked!==true)process.exit(1);
""")
assert result.returncode == 0, result.stderr
def test_sort_public_surface_text_shape():
for key in ("WORKSPACE_SORT_KEYS", "setWorkspaceSortKey", "hermes-workspace-sort-key",
"workspace_sort_by", "workspace_sort_name_asc", "workspace_sort_name_desc",
"workspace_sort_created_desc", "workspace_sort_modified_desc",
"workspace_sort_created_unavailable"):
assert key in UI_JS or key in I18N_JS
assert UI_JS.count("const active=_effectiveWorkspaceSortKey();") >= 2
assert "row.setAttribute('aria-disabled',disabled?'true':'false');" in UI_JS
assert "Number(a[field])" not in UI_JS
assert I18N_JS.count("workspace_sort_by:") == I18N_JS.count("workspace_show_hidden_files:")