Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a018d03cb | |||
| e5f8c7a812 | |||
| 5e09ba1d58 |
@@ -18,6 +18,7 @@ src/
|
||||
platform/
|
||||
autostart/ Manager interface + Windows (shortcut) and Linux (XDG) impls
|
||||
desktop/ display-scale helper (Linux only)
|
||||
filemanager/ open a folder in the desktop file manager
|
||||
winproc/ hidden-window startup flags (Windows only)
|
||||
ui/ Fyne windows, tabs, and dialogs; reads service via Events
|
||||
```
|
||||
|
||||
+38
-6
@@ -2,13 +2,10 @@
|
||||
|
||||
All notable GoSentry changes are recorded in this file.
|
||||
|
||||
## Unreleased
|
||||
## 0.14.0 - 2026-07-26
|
||||
|
||||
**Job dialog:**
|
||||
|
||||
- The **Arguments** placeholder now states the field's rule — one argument per
|
||||
line, no quoting — instead of showing a lone example path that left the
|
||||
line-per-argument convention to guesswork.
|
||||
**Compact job list view, "no timeout" at both timeout levels, and an Open
|
||||
button for the logs folder.**
|
||||
|
||||
**Compact job list view.**
|
||||
|
||||
@@ -23,6 +20,31 @@ All notable GoSentry changes are recorded in this file.
|
||||
so it survives a restart. Empty/legacy configs and any unrecognised value
|
||||
normalize to detailed, so existing installs keep the current look.
|
||||
|
||||
**Jobs sidebar:**
|
||||
|
||||
- The **Folder** caption moved onto the filter row itself, beside the select and
|
||||
the view toggle, instead of occupying its own line above it — the job list now
|
||||
starts a full label higher.
|
||||
|
||||
**Settings:**
|
||||
|
||||
- The **Logs directory** row gained an **Open** button that shows the folder in
|
||||
the desktop file manager (Explorer on Windows, the XDG handler on Linux), so
|
||||
reading a log file no longer means copying the path by hand. It opens the
|
||||
path currently in the field — including an edit that has not been saved yet —
|
||||
resolving a relative directory against the application folder exactly as the
|
||||
store does. A folder that is missing (the logs directory is created on the
|
||||
first run) or cannot be opened is reported in a dialog.
|
||||
- The Save/Cancel/Defaults row sat flush against the separator above it and the
|
||||
tab's left edge; it now uses the same padding as the other vertical gaps in
|
||||
the tab.
|
||||
|
||||
**Job dialog:**
|
||||
|
||||
- The **Arguments** placeholder now states the field's rule — one argument per
|
||||
line, no quoting — instead of showing a lone example path that left the
|
||||
line-per-argument convention to guesswork.
|
||||
|
||||
**Timeouts: 0 now means "no timeout" at both levels.**
|
||||
|
||||
- The global **Default timeout** in Settings now defaults to `0`, meaning jobs
|
||||
@@ -39,6 +61,16 @@ All notable GoSentry changes are recorded in this file.
|
||||
Existing jobs and configs are unaffected: a job with no `timeout_seconds` still
|
||||
inherits, and a saved global default of 30 stays 30.
|
||||
|
||||
**Internal:**
|
||||
|
||||
- Job names in the list are truncated through the widget's `Truncation` field;
|
||||
`fyne.TextTruncate` is deprecated in Fyne 2.7.4. Behavior is unchanged.
|
||||
- Docker release builds mount `.gocache/` from the host, so `--rm` container
|
||||
removal no longer wipes `GOCACHE` between runs.
|
||||
- Added `docs/REVIEW.md` (the project-review agenda) and a "Config file
|
||||
compatibility" section in `docs/STANDARDS.md` recording the rule the `Theme`,
|
||||
`JobListView`, and `TimeoutSeconds` fields already follow. Added `CLAUDE.md`.
|
||||
|
||||
## 0.13.0 - 2026-07-26
|
||||
|
||||
**Branded GoSentry color theme; Cancel/Defaults buttons in Settings.**
|
||||
|
||||
@@ -37,6 +37,88 @@ Design notes / open questions:
|
||||
- *No auto-download.* Scope is detection and notification only; installing the
|
||||
update stays a manual click-through to the release page.
|
||||
|
||||
### Import/export jobs as a cron table
|
||||
|
||||
Jobs can only be moved between machines by copying `jobs.json` by hand. Add
|
||||
"Import" / "Export" actions (Settings tab, file dialogs) that read and write a
|
||||
crontab-style text file, so a job list can be shared, version-controlled, or
|
||||
seeded from an existing Unix crontab.
|
||||
|
||||
Export writes one line per job — schedule fields, then command and arguments —
|
||||
and import parses the same format back into `domain.Job` values.
|
||||
|
||||
Design notes / open questions:
|
||||
|
||||
- *The job model is wider than a crontab line.* `Name`, `Folder`, `StartOnly`,
|
||||
`OverlapPolicy`, `TimeoutSeconds`, and `Enabled` have no cron equivalent.
|
||||
Either accept a lossy export (schedule + command only) or carry the extra
|
||||
fields in a structured comment above each line (`# gosentry: name=… folder=…
|
||||
timeout=…`), which keeps the file readable by real cron while making the
|
||||
round-trip lossless. The comment form is preferred; decide the exact key set
|
||||
before implementing.
|
||||
- *Disabled jobs.* `Enabled: false` maps naturally to a commented-out line, but
|
||||
then a disabled job is indistinguishable from a user's own comment unless the
|
||||
`# gosentry:` marker is present. Pick one representation and document it.
|
||||
- *`@every` is not crontab.* GoSentry accepts `@every 10s` (see
|
||||
[`domain.Parse`](../src/domain/schedule.go)), which no cron implementation
|
||||
understands. Exporting it produces a file that is not a valid crontab;
|
||||
exporting it as an approximation would silently change the schedule. Keep the
|
||||
raw string and flag the file as GoSentry-flavoured, rather than converting.
|
||||
- *Command vs arguments.* Crontab has a single command string; GoSentry splits
|
||||
`Command` and `Arguments`. Import must split the line the same way the runner
|
||||
would (see `runner/invocation*.go`, which differs per OS), and export must
|
||||
join them back without changing quoting.
|
||||
- *What to skip on import.* Environment assignments (`SHELL=`, `PATH=`,
|
||||
`MAILTO=`), six-field (seconds) crontabs, and `@reboot` are outside what
|
||||
`domain.Parse` accepts. Skip them, and report which lines were skipped and
|
||||
why — a partial import that silently drops rows is worse than a failed one.
|
||||
- *Merge semantics.* Import must decide between replacing the job list and
|
||||
appending to it, and must assign fresh IDs rather than trusting the file.
|
||||
Appending with a confirmation dialog is the safer default; replacing needs an
|
||||
explicit "this deletes N jobs" confirmation.
|
||||
- *Where it lives.* Encoding/decoding is pure text handling and belongs in
|
||||
`domain` (or a small `storage` codec) with unit tests over round-trips; the
|
||||
Service exposes import/export operations; the UI only picks the file and
|
||||
shows the outcome.
|
||||
|
||||
### GUI review — custom layouts and composition
|
||||
|
||||
The `ui` package has accumulated hand-written layouts and tuned constants that
|
||||
work but have never been reviewed as a whole:
|
||||
[`layout.go`](../src/ui/layout.go) holds four custom `fyne.Layout`
|
||||
implementations (`minWidthLayout`, `compactVBoxLayout`, `fixedHeightLayout`,
|
||||
`captionValueLayout`), and the views drive them with negative spacings
|
||||
(`detailRowSpacing = -8`, `jobRowSpacing = -8`, `settingsRowSpacing = -6`) that
|
||||
cancel out the built-in padding of Fyne widgets.
|
||||
|
||||
Do a focused pass over composition only — not a general code review — and
|
||||
answer, per layout and per constant: is it still needed, is it the smallest
|
||||
thing that works, and does it hold up at different window sizes and theme
|
||||
scales.
|
||||
|
||||
What to look for:
|
||||
|
||||
- *Negative spacing as a workaround.* Pulling rows together to overlap label
|
||||
padding is a workaround for widget metrics, not a layout decision. Check
|
||||
whether a `widget.Form`, a grid, or a custom text row would express the same
|
||||
result without depending on the padding a future Fyne release may change.
|
||||
- *Hard-coded pixel constants.* Widths and heights expressed in raw pixels
|
||||
(`logColumnMinWidth`, `minJobsSidebarWidth`, `settingsControlWidth`) do not
|
||||
follow `theme.Padding()` / text size, so they behave differently under a
|
||||
scaled UI. `activityRowsHeight` already derives its height from the theme —
|
||||
decide which of the rest should do the same.
|
||||
- *Layouts with one call site.* `fixedHeightLayout` is used once. If a stock
|
||||
container expresses the same intent, deleting the type is a net win — the
|
||||
complexity rule in [REVIEW.md](REVIEW.md) §2 applies to layouts too.
|
||||
- *File size.* `settings_view.go` is well past the ~250-line guideline in
|
||||
[ARCHITECTURE.md](ARCHITECTURE.md) and should be split the way `jobs_view.go`
|
||||
was.
|
||||
- *Behaviour at small sizes.* The details pane was condensed to fit 720p; verify
|
||||
the current composition still degrades sensibly when the window is narrow or
|
||||
short, instead of clipping.
|
||||
|
||||
Findings that are single fixes go straight in; anything larger comes back here.
|
||||
|
||||
### Window size persistence *(frozen)*
|
||||
|
||||
Window size is currently **not** saved on quit or close. Saving was disabled
|
||||
|
||||
@@ -379,6 +379,33 @@ Tests pure History tab helpers (no Fyne widget construction).
|
||||
|
||||
---
|
||||
|
||||
### src/platform/filemanager/filemanager_test.go
|
||||
|
||||
**Package:** `filemanager`
|
||||
|
||||
Tests the guards around opening a folder in the desktop file manager. The
|
||||
success path is not tested: it would open a real file manager window.
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestOpenRejectsMissingFolder` | Verifies that `Open` reports a missing directory (naming the path) instead of launching a handler. |
|
||||
| `TestOpenRejectsFile` | Verifies that `Open` refuses a path that is a file rather than a directory. |
|
||||
| `TestOpenCommandNamesPlatformHandler` | Verifies the per-platform handler (`explorer` / `xdg-open`, none elsewhere) and that the path is passed as one argument. |
|
||||
|
||||
---
|
||||
|
||||
### src/ui/settings_view_test.go
|
||||
|
||||
**Package:** `ui`
|
||||
|
||||
Tests pure Settings tab helpers (no Fyne widget construction).
|
||||
|
||||
| Test | Purpose |
|
||||
|------|---------|
|
||||
| `TestSettingsFolderPath` | Verifies the folder the Logs directory "Open" button targets: blank text yields no path, a relative path resolves against the application directory, an absolute path is used as typed. |
|
||||
|
||||
---
|
||||
|
||||
### src/ui/mainwindow_test.go
|
||||
|
||||
**Package:** `ui`
|
||||
|
||||
+1
-1
@@ -3,4 +3,4 @@ package app
|
||||
// Version is the application version shown in the GUI and used by build
|
||||
// scripts in artifact names. It is a var rather than a const so release builds
|
||||
// can override it with Go ldflags when CI tags a build.
|
||||
var Version = "0.13.0"
|
||||
var Version = "0.14.0"
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Package filemanager opens a directory in the desktop file manager, so the
|
||||
// UI can reveal a configured folder (logs, jobs) without knowing which handler
|
||||
// the platform uses.
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Open shows dir in the platform file manager. A missing path, a path that is
|
||||
// not a directory, and a handler that fails to start are all returned as
|
||||
// errors so the caller can surface them instead of appearing to do nothing.
|
||||
func Open(dir string) error {
|
||||
info, err := os.Stat(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("folder does not exist: %s", dir)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("not a folder: %s", dir)
|
||||
}
|
||||
name, args := openCommand(dir)
|
||||
if name == "" {
|
||||
return fmt.Errorf("opening a folder is not supported on %s", runtime.GOOS)
|
||||
}
|
||||
command := exec.Command(name, args...)
|
||||
if err := command.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
// The handler hands the request to the desktop shell and exits on its own —
|
||||
// Windows Explorer even exits non-zero after opening the window — so its
|
||||
// status carries no information. Wait runs only to release the process
|
||||
// handle, and never blocks the caller.
|
||||
go func() { _ = command.Wait() }()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build linux
|
||||
|
||||
package filemanager
|
||||
|
||||
// openCommand returns the XDG invocation for dir. xdg-open picks whichever
|
||||
// file manager the desktop environment has registered for directories.
|
||||
func openCommand(dir string) (string, []string) {
|
||||
return "xdg-open", []string{dir}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !windows && !linux
|
||||
|
||||
package filemanager
|
||||
|
||||
// openCommand has no handler to name on platforms GoSentry does not ship for.
|
||||
// An empty name makes Open report that the action is unavailable instead of
|
||||
// running something arbitrary.
|
||||
func openCommand(dir string) (string, []string) {
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package filemanager
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The success path is deliberately not tested: it would pop a real file
|
||||
// manager window on the machine running the suite. Only the guards that keep
|
||||
// Open from launching anything are exercised here.
|
||||
|
||||
func TestOpenRejectsMissingFolder(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "no-such-folder")
|
||||
|
||||
err := Open(missing)
|
||||
if err == nil {
|
||||
t.Fatal("Open on a missing folder returned nil, want an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), missing) {
|
||||
t.Errorf("error %q does not name the missing folder %q", err, missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRejectsFile(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "gosentry.log")
|
||||
if err := os.WriteFile(file, []byte("log"), 0o644); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
err := Open(file)
|
||||
if err == nil {
|
||||
t.Fatal("Open on a file returned nil, want an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not a folder") {
|
||||
t.Errorf("error %q does not report that the path is not a folder", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenCommandNamesPlatformHandler checks the supported platforms name a
|
||||
// handler (an empty name makes Open report the action as unavailable) and that
|
||||
// the directory is passed as a single argument, so spaces need no quoting.
|
||||
func TestOpenCommandNamesPlatformHandler(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "log files")
|
||||
|
||||
name, args := openCommand(dir)
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
if name != "explorer" {
|
||||
t.Errorf("handler on windows = %q, want %q", name, "explorer")
|
||||
}
|
||||
case "linux":
|
||||
if name != "xdg-open" {
|
||||
t.Errorf("handler on linux = %q, want %q", name, "xdg-open")
|
||||
}
|
||||
default:
|
||||
if name != "" {
|
||||
t.Errorf("handler on %s = %q, want no handler", runtime.GOOS, name)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(args) != 1 || args[0] != filepath.Clean(dir) {
|
||||
t.Errorf("arguments = %q, want the single path %q", args, filepath.Clean(dir))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package filemanager
|
||||
|
||||
import "path/filepath"
|
||||
|
||||
// openCommand returns the Explorer invocation for dir. The path is cleaned
|
||||
// because Explorer ignores an argument that mixes separators, and it is passed
|
||||
// as a single argument so spaces need no quoting.
|
||||
func openCommand(dir string) (string, []string) {
|
||||
return "explorer", []string{filepath.Clean(dir)}
|
||||
}
|
||||
@@ -163,10 +163,14 @@ func normalizeJobs(jobs []domain.Job) {
|
||||
}
|
||||
|
||||
func resolveJobsDir(appDir string, jobsDir string) string {
|
||||
return resolveConfiguredDir(appDir, jobsDir)
|
||||
return ResolveConfiguredDir(appDir, jobsDir)
|
||||
}
|
||||
|
||||
func resolveConfiguredDir(appDir string, dir string) string {
|
||||
// ResolveConfiguredDir turns a directory from the config into the absolute
|
||||
// path the application will actually use. It is exported so callers outside
|
||||
// storage — the settings tab, which opens the configured logs folder — apply
|
||||
// the same rule to a path the user has typed but not yet saved.
|
||||
func ResolveConfiguredDir(appDir string, dir string) string {
|
||||
if filepath.IsAbs(dir) {
|
||||
return dir
|
||||
}
|
||||
@@ -177,9 +181,9 @@ func resolveConfiguredDir(appDir string, dir string) string {
|
||||
}
|
||||
|
||||
func (s *Store) applyConfigPaths() {
|
||||
s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir)
|
||||
s.Paths.JobsDir = ResolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir)
|
||||
s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName)
|
||||
s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir)
|
||||
s.Paths.LogsDir = ResolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir)
|
||||
}
|
||||
|
||||
func writeJSON(path string, value any) error {
|
||||
|
||||
+39
-1
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"image/color"
|
||||
"net/url"
|
||||
"runtime"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
|
||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/platform/filemanager"
|
||||
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
@@ -103,6 +106,13 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||
chooseFolder(w, logsDir)
|
||||
})
|
||||
// Log files are read outside the app, so the folder gets a direct shortcut
|
||||
// beside its path instead of making the user copy the path into a file
|
||||
// manager. It reveals whatever the field currently holds, so an edit can be
|
||||
// checked before Save.
|
||||
logsDirOpen := widget.NewButtonWithIcon("Open", theme.FolderIcon(), func() {
|
||||
openFolder(w, settingsFolderPath(store.Paths.AppDir, logsDir.Text))
|
||||
})
|
||||
maxLogFiles := widget.NewEntry()
|
||||
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
|
||||
maxLogFiles.OnChanged = func(string) { updateSaveState() }
|
||||
@@ -259,7 +269,9 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
||||
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||
settingsRow("Config JSON", widget.NewLabel(store.Paths.ConfigPath)),
|
||||
settingsRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
|
||||
settingsRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)),
|
||||
// Browse stays rightmost so it lines up with the Jobs directory row
|
||||
// above it; Open sits between it and the path it opens.
|
||||
settingsRow("Logs directory", container.NewBorder(nil, nil, nil, container.NewHBox(logsDirOpen, logsDirBrowse), logsDir)),
|
||||
settingsRow("Max log files", maxLogFiles),
|
||||
settingsRow("Max log age days", maxLogAgeDays),
|
||||
),
|
||||
@@ -359,6 +371,32 @@ func chooseFolder(w fyne.Window, target *widget.Entry) {
|
||||
folderDialog.Show()
|
||||
}
|
||||
|
||||
// settingsFolderPath resolves what a directory field currently points at,
|
||||
// applying the same relative-path rule the store uses when it loads the config
|
||||
// so the folder that opens is the one the setting would use. Blank text has no
|
||||
// folder to open and yields an empty path.
|
||||
func settingsFolderPath(appDir string, text string) string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
return storage.ResolveConfiguredDir(appDir, trimmed)
|
||||
}
|
||||
|
||||
// openFolder reveals dir in the desktop file manager. A folder that is not set
|
||||
// or cannot be opened (most often: it does not exist yet, because the logs
|
||||
// directory is created on the first run) is reported in a dialog rather than
|
||||
// leaving the button looking dead.
|
||||
func openFolder(w fyne.Window, dir string) {
|
||||
if dir == "" {
|
||||
dialog.ShowError(errors.New("no folder is set"), w)
|
||||
return
|
||||
}
|
||||
if err := filemanager.Open(dir); err != nil {
|
||||
dialog.ShowError(err, w)
|
||||
}
|
||||
}
|
||||
|
||||
// Theme dropdown labels. These are the human-facing captions; themeLabel and
|
||||
// themeFromLabel translate between them and the stored domain.Theme values so the
|
||||
// select never leaks the on-disk "default"/"gosentry" strings to the user.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSettingsFolderPath covers the path the "Open" button beside the logs
|
||||
// directory hands to the file manager: blank means nothing to open, a relative
|
||||
// directory resolves against the application directory (as the store does),
|
||||
// and an absolute directory is used as typed. Both directories come from
|
||||
// t.TempDir so the absolute case is genuinely absolute on Windows too.
|
||||
func TestSettingsFolderPath(t *testing.T) {
|
||||
appDir := t.TempDir()
|
||||
absolute := filepath.Join(t.TempDir(), "logs")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
text string
|
||||
want string
|
||||
}{
|
||||
{name: "empty", text: "", want: ""},
|
||||
{name: "whitespace only", text: " ", want: ""},
|
||||
{name: "relative", text: "logs", want: filepath.Join(appDir, "logs")},
|
||||
{name: "relative with spaces around it", text: " logs ", want: filepath.Join(appDir, "logs")},
|
||||
{name: "absolute", text: absolute, want: absolute},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
if got := settingsFolderPath(appDir, testCase.text); got != testCase.want {
|
||||
t.Errorf("settingsFolderPath(%q, %q) = %q, want %q", appDir, testCase.text, got, testCase.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user