From 40212258efb6be11ee467f9cffb7fe18c7c13c5c Mon Sep 17 00:00:00 2001 From: Sal Date: Sun, 30 Aug 2026 19:46:14 +0100 Subject: [PATCH] feat(terminal-demo): constrain and compose VHS tapes --- .../internal/tape/compose.go | 244 +++++++ .../internal/tape/parse.go | 445 +++++++++++++ .../internal/tape/tape_test.go | 599 ++++++++++++++++++ .../invalid/tapes/output-directive.tape | 3 + .../testdata/invalid/tapes/set-directive.tape | 3 + .../invalid/tapes/source-indirection.tape | 3 + .../invalid/tapes/timing-override.tape | 2 + .../testdata/valid/tapes/behavior.tape | 9 + .../testdata/valid/tapes/every-directive.tape | 20 + 9 files changed, 1328 insertions(+) create mode 100644 tools/readme-terminal-demo/internal/tape/compose.go create mode 100644 tools/readme-terminal-demo/internal/tape/parse.go create mode 100644 tools/readme-terminal-demo/internal/tape/tape_test.go create mode 100644 tools/readme-terminal-demo/testdata/invalid/tapes/output-directive.tape create mode 100644 tools/readme-terminal-demo/testdata/invalid/tapes/set-directive.tape create mode 100644 tools/readme-terminal-demo/testdata/invalid/tapes/source-indirection.tape create mode 100644 tools/readme-terminal-demo/testdata/invalid/tapes/timing-override.tape create mode 100644 tools/readme-terminal-demo/testdata/valid/tapes/behavior.tape create mode 100644 tools/readme-terminal-demo/testdata/valid/tapes/every-directive.tape diff --git a/tools/readme-terminal-demo/internal/tape/compose.go b/tools/readme-terminal-demo/internal/tape/compose.go new file mode 100644 index 000000000..665fbc37d --- /dev/null +++ b/tools/readme-terminal-demo/internal/tape/compose.go @@ -0,0 +1,244 @@ +package tape + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/limits" +) + +// composedSetCount is the exact number of Set directives the trusted header +// emits. The composition test asserts against it so an added or removed +// central control cannot pass unnoticed. +const composedSetCount = 9 + +// Compose renders a complete, byte-stable VHS tape from validated directives. +// +// Every central control originates from config and output, both typed +// arguments. Directives supply behavior only. Compose independently revalidates +// its inputs so a caller that constructs a Directive without Parse cannot +// bypass the contract. +func Compose(directives []Directive, config Config, output string) ([]byte, error) { + if err := validateConfig(config); err != nil { + return nil, err + } + if err := validateOutput(output); err != nil { + return nil, err + } + + var builder strings.Builder + + // The trusted header, in one fixed order. + fmt.Fprintf(&builder, "Output %q\n", output) + builder.WriteString("Set Shell \"zsh\"\n") + fmt.Fprintf(&builder, "Set Width %d\n", config.Width) + fmt.Fprintf(&builder, "Set Height %d\n", config.Height) + fmt.Fprintf(&builder, "Set FontFamily %q\n", config.FontFamily) + fmt.Fprintf(&builder, "Set FontSize %d\n", config.FontSize) + fmt.Fprintf(&builder, "Set Theme %q\n", config.Theme) + fmt.Fprintf(&builder, "Set Framerate %d\n", config.Framerate) + fmt.Fprintf(&builder, "Set TypingSpeed %s\n", milliseconds(config.TypingSpeed)) + fmt.Fprintf(&builder, "Set CursorBlink %t\n", config.CursorBlink) + + for _, directive := range directives { + line, err := composeDirective(directive) + if err != nil { + return nil, err + } + builder.WriteString(line) + builder.WriteByte('\n') + } + + return []byte(builder.String()), nil +} + +// validateConfig requires the exact approved presentation contract rather than +// accepting arbitrary presentation strings. +func validateConfig(config Config) error { + if config != DefaultConfig() { + return invalidTape("config", 0, errors.New("presentation config is not the approved v1 contract")) + } + return nil +} + +// validateOutput accepts only an absolute, clean, quote-free .gif path strictly +// below /work. +func validateOutput(output string) error { + if output == "" { + return invalidTape("output", 0, errors.New("output destination is required")) + } + if !strings.HasPrefix(output, outputRoot) { + return invalidTape("output", 0, errors.New("output must be below the /work root")) + } + + name := strings.TrimPrefix(output, outputRoot) + if name == "" { + return invalidTape("output", 0, errors.New("output must name a file")) + } + if !strings.HasSuffix(output, ".gif") { + return invalidTape("output", 0, errors.New("output must use the .gif extension")) + } + for _, segment := range strings.Split(name, "/") { + if segment == "" || segment == "." || segment == ".." { + return invalidTape("output", 0, errors.New("output path is not clean")) + } + } + for i := 0; i < len(output); i++ { + char := output[i] + if char < 0x20 || char == 0x7F { + return invalidTape("output", 0, errors.New("output contains a control byte")) + } + if char == ' ' || char == '\t' || char == '"' || char == '\'' || char == '`' || char == '\\' { + return invalidTape("output", 0, errors.New("output contains a character requiring quoting")) + } + } + return nil +} + +// composeDirective renders one behavior directive, rejecting any Directive +// whose fields are unknown or internally inconsistent. +func composeDirective(directive Directive) (string, error) { + switch directive.Kind { + case KindType: + if err := requireFields(directive, fieldText); err != nil { + return "", err + } + quoted, err := quoteTyped(directive.Text, directive.Line) + if err != nil { + return "", err + } + return "Type " + quoted, nil + + case KindCtrl: + if err := requireFields(directive, fieldText); err != nil { + return "", err + } + if len(directive.Text) != 1 { + return "", invalidTape("ctrl", directive.Line, errors.New("Ctrl requires exactly one character")) + } + if directive.Text[0] < 0x20 || directive.Text[0] > 0x7E { + return "", invalidTape("ctrl", directive.Line, errors.New("Ctrl character must be printable ASCII")) + } + // Quoting the character prevents punctuation from becoming grammar. + quoted, err := quoteTyped(directive.Text, directive.Line) + if err != nil { + return "", err + } + return "Ctrl+" + quoted, nil + + case KindSleep: + if err := requireFields(directive, fieldDuration); err != nil { + return "", err + } + token, err := durationToken(directive.Duration, directive.Line) + if err != nil { + return "", err + } + return "Sleep " + token, nil + + case KindWait, KindWaitLine, KindWaitScreen: + if err := requireFields(directive, fieldDuration|fieldPattern); err != nil { + return "", err + } + token, err := durationToken(directive.Duration, directive.Line) + if err != nil { + return "", err + } + // The fixed timeout keeps Duration authoritative without relying on + // the VHS default or a global Set WaitTimeout. + line := string(directive.Kind) + "@" + token + if directive.Pattern != "" { + if strings.ContainsAny(directive.Pattern, "/\n\r") { + return "", invalidTape("wait", directive.Line, errors.New("wait pattern cannot be delimited safely")) + } + line += " /" + directive.Pattern + "/" + } + return line, nil + } + + if isRepeatableKey(directive.Kind) { + if err := requireFields(directive, 0); err != nil { + return "", err + } + if directive.Count == 1 { + return string(directive.Kind), nil + } + return string(directive.Kind) + " " + strconv.Itoa(directive.Count), nil + } + + return "", invalidTape("directive", directive.Line, errors.New("directive kind is not permitted by the v1 contract")) +} + +// field flags name the optional Directive fields a kind may populate. +type field uint8 + +const ( + fieldText field = 1 << iota + fieldDuration + fieldPattern +) + +// requireFields rejects a Directive carrying a field its kind does not use, so +// an inconsistent value cannot be silently ignored during composition. +func requireFields(directive Directive, allowed field) error { + bounds := limits.V1() + if directive.Count < 1 || directive.Count > bounds.KeyRepeat { + return invalidTape("directive", directive.Line, errors.New("directive count is outside its permitted range")) + } + if allowed&fieldText == 0 && directive.Text != "" { + return invalidTape("directive", directive.Line, errors.New("directive does not accept text")) + } + if allowed&fieldText != 0 && directive.Text == "" { + return invalidTape("directive", directive.Line, errors.New("directive requires text")) + } + if allowed&fieldDuration == 0 && directive.Duration != 0 { + return invalidTape("directive", directive.Line, errors.New("directive does not accept a duration")) + } + if allowed&fieldDuration != 0 && directive.Duration <= 0 { + return invalidTape("directive", directive.Line, errors.New("directive requires a positive duration")) + } + if allowed&fieldPattern == 0 && directive.Pattern != "" { + return invalidTape("directive", directive.Line, errors.New("directive does not accept a pattern")) + } + return nil +} + +// quoteTyped selects a delimiter absent from the text. VHS performs no escape +// interpretation, so text containing all three delimiters cannot be expressed. +func quoteTyped(text string, line int) (string, error) { + if strings.ContainsAny(text, "\n\r") { + return "", invalidTape("type", line, errors.New("typed text cannot contain a line break")) + } + for _, delimiter := range []string{`"`, `'`, "`"} { + if !strings.Contains(text, delimiter) { + return delimiter + text + delimiter, nil + } + } + return "", invalidTape("type", line, errors.New("typed text cannot be quoted safely")) +} + +// durationToken renders a duration as one VHS time token. VHS accepts exactly +// one number plus one unit, so a compound value such as 1m30s is rejected +// rather than emitted in a form VHS would misparse. +func durationToken(duration time.Duration, line int) (string, error) { + if duration <= 0 { + return "", invalidTape("duration", line, errors.New("duration must be positive")) + } + switch { + case duration%time.Minute == 0 && duration < time.Hour: + return strconv.FormatInt(int64(duration/time.Minute), 10) + "m", nil + case duration%time.Second == 0 && duration < time.Minute: + return strconv.FormatInt(int64(duration/time.Second), 10) + "s", nil + case duration%time.Millisecond == 0 && duration < time.Minute: + return strconv.FormatInt(int64(duration/time.Millisecond), 10) + "ms", nil + } + return "", invalidTape("duration", line, errors.New("duration is not expressible as one VHS time token")) +} + +// milliseconds renders a typing speed in the exact normative form. +func milliseconds(duration time.Duration) string { + return strconv.FormatInt(int64(duration/time.Millisecond), 10) + "ms" +} diff --git a/tools/readme-terminal-demo/internal/tape/parse.go b/tools/readme-terminal-demo/internal/tape/parse.go new file mode 100644 index 000000000..6feadd5cb --- /dev/null +++ b/tools/readme-terminal-demo/internal/tape/parse.go @@ -0,0 +1,445 @@ +// Package tape parses behavior-only VHS tapes and composes trusted presentation. +// +// A plugin-owned tape declares interaction behavior and nothing else. Every +// presentation control, the output destination, and the shell originate from +// typed configuration held by this package, never from tape text. Parsing is +// data-only: it never invokes a shell, VHS, the filesystem, the environment, +// or the network, and it never matches a wait pattern. +package tape + +import ( + "bufio" + "errors" + "fmt" + "io" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/failure" + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/limits" +) + +// Kind is the closed set of directives the v1 contract accepts. +type Kind string + +const ( + KindType Kind = "Type" + KindEnter Kind = "Enter" + KindTab Kind = "Tab" + KindSpace Kind = "Space" + KindBackspace Kind = "Backspace" + KindLeft Kind = "Left" + KindRight Kind = "Right" + KindUp Kind = "Up" + KindDown Kind = "Down" + KindPageUp Kind = "PageUp" + KindPageDown Kind = "PageDown" + KindScrollUp Kind = "ScrollUp" + KindScrollDown Kind = "ScrollDown" + KindCtrl Kind = "Ctrl" + KindSleep Kind = "Sleep" + KindWait Kind = "Wait" + KindWaitLine Kind = "Wait+Line" + KindWaitScreen Kind = "Wait+Screen" +) + +// repeatableKeys is the closed set of directives accepting an optional count. +var repeatableKeys = map[Kind]struct{}{ + KindEnter: {}, + KindTab: {}, + KindSpace: {}, + KindBackspace: {}, + KindLeft: {}, + KindRight: {}, + KindUp: {}, + KindDown: {}, + KindPageUp: {}, + KindPageDown: {}, + KindScrollUp: {}, + KindScrollDown: {}, +} + +// waitKinds is the closed set of wait directives and their tape spellings. +var waitKinds = map[string]Kind{ + "Wait": KindWait, + "Wait+Line": KindWaitLine, + "Wait+Screen": KindWaitScreen, +} + +// Directive is one validated behavior instruction. +type Directive struct { + Kind Kind + Text string + Count int + Duration time.Duration + Pattern string + Line int +} + +// Config holds the trusted presentation contract. It lives in package tape so +// that render may depend on tape without creating an import cycle. +type Config struct { + Width int + Height int + FontFamily string + FontSize int + Theme string + Framerate int + TypingSpeed time.Duration + CursorBlink bool +} + +// DefaultConfig returns the only presentation contract v1 permits. +func DefaultConfig() Config { + bounds := limits.V1() + return Config{ + Width: bounds.Width, + Height: bounds.Height, + FontFamily: "JetBrains Mono", + FontSize: 18, + Theme: "Catppuccin Mocha", + Framerate: 30, + TypingSpeed: 35 * time.Millisecond, + CursorBlink: false, + } +} + +// outputRoot is the only directory a composed tape may write into. +const outputRoot = "/work/" + +// invalidTape builds the single sanitized failure this package may return. The +// field names a bounded schema-owned category and line number only; it never +// carries tape text, a pattern, a command, or an output path. +func invalidTape(category string, line int, err error) error { + field := category + if line > 0 { + field = fmt.Sprintf("%s:%d", category, line) + } + return failure.E(failure.InvalidContract, failure.StageTape, field, failure.RuleTapeInvalid, err) +} + +// Parse reads a behavior-only tape and returns its validated directives. +func Parse(r io.Reader, bounds limits.Limits) ([]Directive, error) { + if r == nil { + return nil, invalidTape("tape", 0, errors.New("tape reader is required")) + } + + // Read one byte beyond the bound so an oversized tape is detectable + // without buffering an unbounded amount of input. + limited := io.LimitReader(r, bounds.TapeBytes+1) + content, err := io.ReadAll(limited) + if err != nil { + return nil, invalidTape("tape", 0, errors.New("tape could not be read")) + } + if int64(len(content)) > bounds.TapeBytes { + return nil, invalidTape("tape", 0, errors.New("tape exceeds its byte bound")) + } + if !utf8.Valid(content) { + return nil, invalidTape("tape", 0, errors.New("tape is not valid UTF-8")) + } + + var ( + directives []Directive + typedTotal int + sleepTotal time.Duration + waitTotal time.Duration + ) + + scanner := bufio.NewScanner(strings.NewReader(string(content))) + scanner.Buffer(make([]byte, 0, 64*1024), int(bounds.TapeBytes)+1) + + for line := 1; scanner.Scan(); line++ { + text := strings.TrimSuffix(scanner.Text(), "\r") + trimmed := strings.TrimSpace(text) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + directive, err := parseDirective(trimmed, line, bounds) + if err != nil { + return nil, err + } + + switch directive.Kind { + case KindType: + typedTotal += len(directive.Text) + if typedTotal > bounds.TypedBytes { + return nil, invalidTape("typed-total", line, errors.New("typed bytes exceed the cumulative bound")) + } + case KindSleep: + sleepTotal += directive.Duration + if sleepTotal > bounds.SleepTotal { + return nil, invalidTape("sleep-total", line, errors.New("sleep exceeds the cumulative bound")) + } + case KindWait, KindWaitLine, KindWaitScreen: + waitTotal += directive.Duration + if waitTotal > bounds.WaitTotal { + return nil, invalidTape("wait-total", line, errors.New("wait exceeds the cumulative bound")) + } + } + + directives = append(directives, directive) + if len(directives) > bounds.Directives { + return nil, invalidTape("directive-count", line, errors.New("tape exceeds its directive bound")) + } + } + if err := scanner.Err(); err != nil { + return nil, invalidTape("tape", 0, errors.New("tape could not be scanned")) + } + + return directives, nil +} + +// parseDirective validates exactly one non-empty, non-comment line. +func parseDirective(line string, number int, bounds limits.Limits) (Directive, error) { + name, rest := splitName(line) + + // Reject every timing override before dispatch so no directive can smuggle + // an @ form through its own argument parser. + if strings.Contains(name, "@") { + return Directive{}, invalidTape("timing-override", number, errors.New("per-directive timing is not permitted")) + } + if strings.HasPrefix(strings.TrimSpace(rest), "@") { + return Directive{}, invalidTape("timing-override", number, errors.New("per-directive timing is not permitted")) + } + + switch { + case name == string(KindType): + return parseType(rest, number, bounds) + case strings.HasPrefix(name, string(KindCtrl)): + return parseCtrl(name, rest, number) + case name == string(KindSleep): + return parseSleep(rest, number, bounds) + } + + if kind, ok := waitKinds[name]; ok { + return parseWait(kind, rest, number, bounds) + } + if kind := Kind(name); isRepeatableKey(kind) { + return parseKey(kind, rest, number, bounds) + } + + return Directive{}, invalidTape("directive", number, errors.New("directive is not permitted by the v1 contract")) +} + +// splitName separates the leading directive token from its arguments. +func splitName(line string) (string, string) { + index := strings.IndexAny(line, " \t") + if index < 0 { + return line, "" + } + return line[:index], strings.TrimSpace(line[index+1:]) +} + +func isRepeatableKey(kind Kind) bool { + _, ok := repeatableKeys[kind] + return ok +} + +// parseType accepts exactly one quoted literal with no escape interpretation, +// matching the VHS lexer, which reads a string verbatim until its delimiter. +func parseType(rest string, number int, bounds limits.Limits) (Directive, error) { + if rest == "" { + return Directive{}, invalidTape("type", number, errors.New("Type requires one quoted literal")) + } + + delimiter := rest[0] + if delimiter != '"' && delimiter != '\'' && delimiter != '`' { + return Directive{}, invalidTape("type", number, errors.New("Type requires a quoted literal")) + } + + closing := strings.IndexByte(rest[1:], delimiter) + if closing < 0 { + return Directive{}, invalidTape("type", number, errors.New("Type literal is unterminated")) + } + + payload := rest[1 : 1+closing] + if remainder := strings.TrimSpace(rest[2+closing:]); remainder != "" { + return Directive{}, invalidTape("type", number, errors.New("Type accepts exactly one literal")) + } + if len(payload) > bounds.TypedCommandBytes { + return Directive{}, invalidTape("type", number, errors.New("typed command exceeds its byte bound")) + } + + return Directive{Kind: KindType, Text: payload, Count: 1, Line: number}, nil +} + +// parseCtrl accepts Ctrl+ and nothing else. +func parseCtrl(name, rest string, number int) (Directive, error) { + if rest != "" { + return Directive{}, invalidTape("ctrl", number, errors.New("Ctrl accepts no argument")) + } + suffix, ok := strings.CutPrefix(name, string(KindCtrl)+"+") + if !ok { + return Directive{}, invalidTape("ctrl", number, errors.New("Ctrl requires one character")) + } + if len(suffix) != 1 { + return Directive{}, invalidTape("ctrl", number, errors.New("Ctrl accepts exactly one printable ASCII character")) + } + if suffix[0] < 0x20 || suffix[0] > 0x7E { + return Directive{}, invalidTape("ctrl", number, errors.New("Ctrl character must be printable ASCII")) + } + return Directive{Kind: KindCtrl, Text: suffix, Count: 1, Line: number}, nil +} + +// parseKey accepts an allowlisted key with an optional base-10 repeat count. +func parseKey(kind Kind, rest string, number int, bounds limits.Limits) (Directive, error) { + count := 1 + if rest != "" { + if strings.ContainsAny(rest, " \t") { + return Directive{}, invalidTape("key", number, errors.New("key accepts at most one count")) + } + parsed, err := parseCount(rest, bounds) + if err != nil { + return Directive{}, invalidTape("key", number, err) + } + count = parsed + } + return Directive{Kind: kind, Count: count, Line: number}, nil +} + +// parseCount accepts only an unsigned base-10 integer within the repeat bound. +func parseCount(token string, bounds limits.Limits) (int, error) { + for i := 0; i < len(token); i++ { + if token[i] < '0' || token[i] > '9' { + return 0, errors.New("count must be an unsigned base-10 integer") + } + } + value, err := strconv.Atoi(token) + if err != nil { + return 0, errors.New("count is not a valid integer") + } + if value < 1 || value > bounds.KeyRepeat { + return 0, errors.New("count is outside its permitted range") + } + return value, nil +} + +// durationPattern matches one VHS time token: a decimal number with an +// optional ms, s, or m unit. VHS parses exactly one number plus one unit. +var durationPattern = regexp.MustCompile(`^([0-9]+(?:\.[0-9]+)?)(ms|s|m)?$`) + +// parseSleep accepts exactly one positive VHS duration token. +func parseSleep(rest string, number int, bounds limits.Limits) (Directive, error) { + if rest == "" { + return Directive{}, invalidTape("sleep", number, errors.New("Sleep requires one duration")) + } + if strings.ContainsAny(rest, " \t") { + return Directive{}, invalidTape("sleep", number, errors.New("Sleep accepts exactly one duration")) + } + + duration, err := parseDurationToken(rest) + if err != nil { + return Directive{}, invalidTape("sleep", number, err) + } + if duration <= 0 { + return Directive{}, invalidTape("sleep", number, errors.New("Sleep requires a positive duration")) + } + if duration > bounds.Sleep { + return Directive{}, invalidTape("sleep", number, errors.New("Sleep exceeds its single-directive bound")) + } + // The composer emits one VHS time token whose finest unit is a + // millisecond, so a finer value could not survive a parse/compose round + // trip. Reject it here rather than silently truncating it later. + if duration%time.Millisecond != 0 { + return Directive{}, invalidTape("sleep", number, errors.New("Sleep must be a whole number of milliseconds")) + } + return Directive{Kind: KindSleep, Count: 1, Duration: duration, Line: number}, nil +} + +// parseDurationToken converts a VHS time token without floating-point +// arithmetic, so a value such as 0.1ms cannot silently truncate to zero. +func parseDurationToken(token string) (time.Duration, error) { + match := durationPattern.FindStringSubmatch(token) + if match == nil { + return 0, errors.New("duration is not a valid VHS time token") + } + + unit := match[2] + if unit == "" { + // VHS treats a missing unit as seconds. + unit = "s" + } + + var scale time.Duration + switch unit { + case "ms": + scale = time.Millisecond + case "s": + scale = time.Second + case "m": + scale = time.Minute + } + + number := match[1] + whole, fraction, _ := strings.Cut(number, ".") + + value, err := strconv.ParseInt(whole, 10, 64) + if err != nil { + return 0, errors.New("duration magnitude is out of range") + } + total := time.Duration(value) * scale + + // Apply the fractional part by exact integer scaling so precision loss is + // detected rather than rounded away. + for _, digit := range fraction { + scale /= 10 + if scale == 0 { + return 0, errors.New("duration is more precise than one nanosecond") + } + total += time.Duration(digit-'0') * scale + } + return total, nil +} + +// parseWait accepts an optional slash-delimited pattern, compiled only to +// validate its syntax. No match is ever performed here. +func parseWait(kind Kind, rest string, number int, bounds limits.Limits) (Directive, error) { + directive := Directive{Kind: kind, Count: 1, Duration: bounds.Wait, Line: number} + if rest == "" { + return directive, nil + } + if rest[0] != '/' { + return Directive{}, invalidTape("wait", number, errors.New("Wait pattern must be slash-delimited")) + } + + pattern, remainder, err := cutRegex(rest) + if err != nil { + return Directive{}, invalidTape("wait", number, err) + } + if strings.TrimSpace(remainder) != "" { + return Directive{}, invalidTape("wait", number, errors.New("Wait accepts exactly one pattern")) + } + if len(pattern) > bounds.WaitPatternBytes { + return Directive{}, invalidTape("wait", number, errors.New("wait pattern exceeds its byte bound")) + } + if _, err := regexp.Compile(pattern); err != nil { + return Directive{}, invalidTape("wait", number, errors.New("wait pattern is not a valid regular expression")) + } + + directive.Pattern = pattern + return directive, nil +} + +// cutRegex locates the closing delimiter using the VHS odd-backslash rule: a +// delimiter preceded by an odd number of backslashes is escaped. +func cutRegex(input string) (string, string, error) { + var backslashes int + for i := 1; i < len(input); i++ { + switch input[i] { + case '\\': + backslashes++ + case '/': + if backslashes%2 == 0 { + return input[1:i], input[i+1:], nil + } + backslashes = 0 + default: + backslashes = 0 + } + } + return "", "", errors.New("wait pattern delimiter is unterminated") +} diff --git a/tools/readme-terminal-demo/internal/tape/tape_test.go b/tools/readme-terminal-demo/internal/tape/tape_test.go new file mode 100644 index 000000000..a777c5bf6 --- /dev/null +++ b/tools/readme-terminal-demo/internal/tape/tape_test.go @@ -0,0 +1,599 @@ +package tape + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/failure" + "github.com/z-shell/.github/tools/readme-terminal-demo/internal/limits" +) + +const testOutput = "/work/demo.gif" + +func parseString(t *testing.T, input string) ([]Directive, error) { + t.Helper() + return Parse(strings.NewReader(input), limits.V1()) +} + +// assertTapeInvalid proves a rejection uses only the stable sanitized contract +// and never leaks tape source text through the public error string. +func assertTapeInvalid(t *testing.T, err error, secrets ...string) { + t.Helper() + if err == nil { + t.Fatal("expected a failure, got nil") + } + if got := failure.Classify(err); got != failure.InvalidContract { + t.Errorf("class = %q, want %q", got, failure.InvalidContract) + } + if got := failure.ExitCode(err); got != 2 { + t.Errorf("exit code = %d, want 2", got) + } + var structured *failure.Error + if !errors.As(err, &structured) { + t.Fatalf("error is not a *failure.Error: %v", err) + } + if structured.Stage != failure.StageTape { + t.Errorf("stage = %q, want %q", structured.Stage, failure.StageTape) + } + if structured.Rule != failure.RuleTapeInvalid { + t.Errorf("rule = %q, want %q", structured.Rule, failure.RuleTapeInvalid) + } + message := err.Error() + for _, secret := range secrets { + if secret != "" && strings.Contains(message, secret) { + t.Errorf("error message %q leaks source text %q", message, secret) + } + if secret != "" && strings.Contains(structured.Field, secret) { + t.Errorf("error field %q leaks source text %q", structured.Field, secret) + } + } +} + +func TestParseAcceptsEveryAllowedDirective(t *testing.T) { + input := strings.Join([]string{ + "# a comment line", + " # an indented comment", + "", + `Type "ll --git"`, + "Enter", + "Tab", + "Space", + "Backspace 3", + "Left", + "Right 2", + "Up", + "Down 4", + "PageUp", + "PageDown", + "ScrollUp 2", + "ScrollDown", + "Ctrl+L", + "Sleep 500ms", + "Sleep 1s", + "Wait", + "Wait+Line", + `Wait+Screen /\$ $/`, + }, "\n") + + directives, err := parseString(t, input) + if err != nil { + t.Fatalf("Parse returned an unexpected error: %v", err) + } + + want := []Directive{ + {Kind: KindType, Text: "ll --git", Count: 1, Line: 4}, + {Kind: KindEnter, Count: 1, Line: 5}, + {Kind: KindTab, Count: 1, Line: 6}, + {Kind: KindSpace, Count: 1, Line: 7}, + {Kind: KindBackspace, Count: 3, Line: 8}, + {Kind: KindLeft, Count: 1, Line: 9}, + {Kind: KindRight, Count: 2, Line: 10}, + {Kind: KindUp, Count: 1, Line: 11}, + {Kind: KindDown, Count: 4, Line: 12}, + {Kind: KindPageUp, Count: 1, Line: 13}, + {Kind: KindPageDown, Count: 1, Line: 14}, + {Kind: KindScrollUp, Count: 2, Line: 15}, + {Kind: KindScrollDown, Count: 1, Line: 16}, + {Kind: KindCtrl, Text: "L", Count: 1, Line: 17}, + {Kind: KindSleep, Count: 1, Duration: 500 * time.Millisecond, Line: 18}, + {Kind: KindSleep, Count: 1, Duration: time.Second, Line: 19}, + {Kind: KindWait, Count: 1, Duration: 10 * time.Second, Line: 20}, + {Kind: KindWaitLine, Count: 1, Duration: 10 * time.Second, Line: 21}, + {Kind: KindWaitScreen, Count: 1, Duration: 10 * time.Second, Pattern: `\$ $`, Line: 22}, + } + + if len(directives) != len(want) { + t.Fatalf("parsed %d directives, want %d", len(directives), len(want)) + } + for i, expected := range want { + if directives[i] != expected { + t.Errorf("directive %d = %+v, want %+v", i, directives[i], expected) + } + } +} + +func TestParseRejects(t *testing.T) { + bounds := limits.V1() + + cases := []struct { + name string + input string + }{ + {"unknown directive", `Frobnicate "x"`}, + {"output directive", `Output "/work/evil.gif"`}, + {"set directive", `Set FontSize 40`}, + {"require directive", `Require "eza"`}, + {"source indirection", `Source "other.tape"`}, + {"lowercase source indirection", `source "other.tape"`}, + {"env directive", `Env KEY "value"`}, + {"screenshot directive", `Screenshot "/work/x.png"`}, + {"copy directive", `Copy "x"`}, + {"paste directive", "Paste"}, + {"hide directive", "Hide"}, + {"show directive", "Show"}, + {"escape directive", "Escape"}, + {"alt modifier", "Alt+x"}, + {"shift modifier", "Shift+x"}, + {"type timing override", `Type@50ms "x"`}, + {"type spaced timing override", `Type @50ms "x"`}, + {"key timing override", "Enter@1s"}, + {"wait timing override", "Wait@5s"}, + {"sleep timing override", "Sleep@1s"}, + {"unquoted type", "Type hello"}, + {"unterminated quote", `Type "hello`}, + {"mismatched quotes", `Type "hello'`}, + {"two type literals", `Type "a" "b"`}, + {"trailing token after type", `Type "a" extra`}, + {"inline comment", "Enter # go"}, + {"trailing token after key", "Enter now"}, + {"negative repeat", "Down -1"}, + {"zero repeat", "Down 0"}, + {"signed repeat", "Down +2"}, + {"fractional repeat", "Down 1.5"}, + {"excessive repeat", "Down 33"}, + {"repeat on ctrl", "Ctrl+L 2"}, + {"ctrl without character", "Ctrl+"}, + {"ctrl bare", "Ctrl"}, + {"ctrl multi character", "Ctrl+Shift"}, + {"ctrl modifier chain", "Ctrl+Alt+p"}, + {"ctrl non ascii", "Ctrl+é"}, + {"sleep without duration", "Sleep"}, + {"sleep zero", "Sleep 0s"}, + {"sleep negative", "Sleep -1s"}, + {"sleep exceeds single bound", "Sleep 4s"}, + {"sleep unknown unit", "Sleep 1h"}, + {"sleep sub millisecond precision", "Sleep 0.0001ms"}, + {"sleep trailing token", "Sleep 1s extra"}, + {"wait malformed regex delimiter", "Wait /unterminated"}, + {"wait invalid regex", `Wait /(unclosed/`}, + {"wait trailing token after regex", "Wait /ok/ extra"}, + {"wait unknown suffix", "Wait+Frame /x/"}, + {"blank directive name only plus", "+"}, + {"leading illegal token", "@"}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + _, err := parseString(t, testCase.input) + assertTapeInvalid(t, err, testCase.input) + }) + } + + t.Run("excessive directive count", func(t *testing.T) { + input := strings.Repeat("Enter\n", bounds.Directives+1) + _, err := parseString(t, input) + assertTapeInvalid(t, err) + }) + + t.Run("excessive single typed command bytes", func(t *testing.T) { + payload := strings.Repeat("a", bounds.TypedCommandBytes+1) + _, err := parseString(t, `Type "`+payload+`"`) + assertTapeInvalid(t, err) + }) + + t.Run("excessive total typed bytes", func(t *testing.T) { + line := `Type "` + strings.Repeat("a", 1024) + `"` + "\n" + input := strings.Repeat(line, (bounds.TypedBytes/1024)+1) + _, err := parseString(t, input) + assertTapeInvalid(t, err) + }) + + t.Run("excessive cumulative sleep", func(t *testing.T) { + input := strings.Repeat("Sleep 3s\n", 4) + _, err := parseString(t, input) + assertTapeInvalid(t, err) + }) + + t.Run("excessive cumulative wait", func(t *testing.T) { + input := strings.Repeat("Wait\n", 4) + _, err := parseString(t, input) + assertTapeInvalid(t, err) + }) + + t.Run("excessive wait pattern bytes", func(t *testing.T) { + pattern := strings.Repeat("a", bounds.WaitPatternBytes+1) + _, err := parseString(t, "Wait /"+pattern+"/") + assertTapeInvalid(t, err) + }) + + t.Run("excessive tape bytes", func(t *testing.T) { + input := strings.Repeat("# padding comment\n", 8*1024) + if int64(len(input)) <= bounds.TapeBytes { + t.Fatalf("test input is not larger than the tape bound") + } + _, err := parseString(t, input) + assertTapeInvalid(t, err) + }) + + t.Run("invalid utf-8", func(t *testing.T) { + _, err := parseString(t, "Type \"\xff\xfe\"") + assertTapeInvalid(t, err) + }) +} + +func TestParseAcceptsCarriageReturnLineEndings(t *testing.T) { + directives, err := parseString(t, "Type \"ok\"\r\nEnter\r\n") + if err != nil { + t.Fatalf("Parse returned an unexpected error: %v", err) + } + if len(directives) != 2 { + t.Fatalf("parsed %d directives, want 2", len(directives)) + } + if directives[0].Text != "ok" { + t.Errorf("typed text = %q, want %q", directives[0].Text, "ok") + } +} + +func TestParseAcceptsFinalLineWithoutNewline(t *testing.T) { + directives, err := parseString(t, "Enter") + if err != nil { + t.Fatalf("Parse returned an unexpected error: %v", err) + } + if len(directives) != 1 || directives[0].Kind != KindEnter { + t.Fatalf("directives = %+v, want a single Enter", directives) + } +} + +func TestParsePreservesQuotedPayloadsVerbatim(t *testing.T) { + // Quoting only delimits the literal: VHS performs no escape interpretation, + // so a backslash sequence must survive parsing byte-for-byte. + directives, err := parseString(t, `Type 'printf "a\nb"'`) + if err != nil { + t.Fatalf("Parse returned an unexpected error: %v", err) + } + if want := `printf "a\nb"`; directives[0].Text != want { + t.Errorf("typed text = %q, want %q", directives[0].Text, want) + } +} + +func TestParseTreatsSourceOnlyAsTypedPayload(t *testing.T) { + directives, err := parseString(t, `Type "source ~/.zshrc"`) + if err != nil { + t.Fatalf("Parse returned an unexpected error: %v", err) + } + if len(directives) != 1 || directives[0].Kind != KindType { + t.Fatalf("directives = %+v, want a single Type", directives) + } + if want := "source ~/.zshrc"; directives[0].Text != want { + t.Errorf("typed text = %q, want %q", directives[0].Text, want) + } +} + +func TestComposeGolden(t *testing.T) { + input := strings.Join([]string{ + "# behavior only", + `Type "ll"`, + "Enter", + "Sleep 1s", + `Wait+Screen /\$ $/`, + "Down 3", + "Ctrl+L", + "Wait", + "Wait+Line", + }, "\n") + + directives, err := parseString(t, input) + if err != nil { + t.Fatalf("Parse returned an unexpected error: %v", err) + } + + got, err := Compose(directives, DefaultConfig(), testOutput) + if err != nil { + t.Fatalf("Compose returned an unexpected error: %v", err) + } + + want := strings.Join([]string{ + `Output "/work/demo.gif"`, + `Set Shell "zsh"`, + "Set Width 960", + "Set Height 540", + `Set FontFamily "JetBrains Mono"`, + "Set FontSize 18", + `Set Theme "Catppuccin Mocha"`, + "Set Framerate 30", + "Set TypingSpeed 35ms", + "Set CursorBlink false", + `Type "ll"`, + "Enter", + "Sleep 1s", + `Wait+Screen@10s /\$ $/`, + "Down 3", + `Ctrl+"L"`, + "Wait@10s", + "Wait+Line@10s", + "", + }, "\n") + + if string(got) != want { + t.Errorf("composed tape mismatch\n got:\n%s\nwant:\n%s", got, want) + } + + // Byte stability: the same inputs must produce identical bytes. + again, err := Compose(directives, DefaultConfig(), testOutput) + if err != nil { + t.Fatalf("second Compose returned an unexpected error: %v", err) + } + if string(again) != string(got) { + t.Error("Compose is not byte-stable across identical invocations") + } +} + +func TestComposeSelectsSafeDelimiterForTypedText(t *testing.T) { + cases := []struct { + name string + text string + want string + }{ + {"plain text prefers double quotes", `ll`, `Type "ll"`}, + {"text with double quote falls back to single", `echo "hi"`, `Type 'echo "hi"'`}, + {"text with both quotes falls back to backtick", `echo "a" 'b'`, "Type `echo \"a\" 'b'`"}, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + directives := []Directive{{Kind: KindType, Text: testCase.text, Count: 1, Line: 1}} + got, err := Compose(directives, DefaultConfig(), testOutput) + if err != nil { + t.Fatalf("Compose returned an unexpected error: %v", err) + } + if !strings.Contains(string(got), testCase.want) { + t.Errorf("composed tape does not contain %q\ngot:\n%s", testCase.want, got) + } + }) + } +} + +func TestComposeRejects(t *testing.T) { + valid := []Directive{{Kind: KindEnter, Count: 1, Line: 1}} + + t.Run("text containing every delimiter", func(t *testing.T) { + directives := []Directive{{Kind: KindType, Text: "a\"b'c`d", Count: 1, Line: 1}} + _, err := Compose(directives, DefaultConfig(), testOutput) + assertTapeInvalid(t, err, "a\"b'c`d") + }) + + t.Run("unknown kind constructed without Parse", func(t *testing.T) { + directives := []Directive{{Kind: Kind("Screenshot"), Count: 1, Line: 1}} + _, err := Compose(directives, DefaultConfig(), testOutput) + assertTapeInvalid(t, err) + }) + + t.Run("empty kind", func(t *testing.T) { + directives := []Directive{{Count: 1, Line: 1}} + _, err := Compose(directives, DefaultConfig(), testOutput) + assertTapeInvalid(t, err) + }) + + t.Run("internally inconsistent key directive", func(t *testing.T) { + directives := []Directive{{Kind: KindEnter, Text: "unexpected", Count: 1, Line: 1}} + _, err := Compose(directives, DefaultConfig(), testOutput) + assertTapeInvalid(t, err) + }) + + t.Run("key repeat out of range", func(t *testing.T) { + directives := []Directive{{Kind: KindDown, Count: 0, Line: 1}} + _, err := Compose(directives, DefaultConfig(), testOutput) + assertTapeInvalid(t, err) + }) + + t.Run("ctrl with multi character text", func(t *testing.T) { + directives := []Directive{{Kind: KindCtrl, Text: "Shift", Count: 1, Line: 1}} + _, err := Compose(directives, DefaultConfig(), testOutput) + assertTapeInvalid(t, err) + }) + + t.Run("sleep with compound duration", func(t *testing.T) { + // VHS parseTime accepts one number plus one unit; a compound Go + // duration such as 1m30s cannot be represented. + directives := []Directive{{Kind: KindSleep, Count: 1, Duration: 90 * time.Second, Line: 1}} + _, err := Compose(directives, DefaultConfig(), testOutput) + assertTapeInvalid(t, err) + }) + + t.Run("sleep with sub millisecond duration", func(t *testing.T) { + directives := []Directive{{Kind: KindSleep, Count: 1, Duration: 500 * time.Microsecond, Line: 1}} + _, err := Compose(directives, DefaultConfig(), testOutput) + assertTapeInvalid(t, err) + }) + + t.Run("wait pattern containing the delimiter", func(t *testing.T) { + directives := []Directive{{Kind: KindWait, Count: 1, Duration: 10 * time.Second, Pattern: "a/b", Line: 1}} + _, err := Compose(directives, DefaultConfig(), testOutput) + assertTapeInvalid(t, err) + }) + + outputs := []struct { + name string + output string + }{ + {"relative output", "work/demo.gif"}, + {"output outside work", "/tmp/demo.gif"}, + {"work root itself", "/work"}, + {"work directory", "/work/"}, + {"traversal", "/work/../etc/demo.gif"}, + {"unclean path", "/work/./demo.gif"}, + {"wrong extension", "/work/demo.png"}, + {"whitespace", "/work/my demo.gif"}, + {"tab", "/work/demo\t.gif"}, + {"control byte", "/work/demo\x00.gif"}, + {"double quote", `/work/de"mo.gif`}, + {"newline", "/work/demo\n.gif"}, + {"empty", ""}, + } + for _, testCase := range outputs { + t.Run("output "+testCase.name, func(t *testing.T) { + _, err := Compose(valid, DefaultConfig(), testCase.output) + assertTapeInvalid(t, err, testCase.output) + }) + } + + configs := []struct { + name string + mutate func(*Config) + }{ + {"width", func(c *Config) { c.Width = 800 }}, + {"height", func(c *Config) { c.Height = 300 }}, + {"font family", func(c *Config) { c.FontFamily = "Comic Sans" }}, + {"font size", func(c *Config) { c.FontSize = 24 }}, + {"theme", func(c *Config) { c.Theme = "Dracula" }}, + {"framerate", func(c *Config) { c.Framerate = 60 }}, + {"typing speed", func(c *Config) { c.TypingSpeed = time.Second }}, + {"cursor blink", func(c *Config) { c.CursorBlink = true }}, + } + for _, testCase := range configs { + t.Run("config "+testCase.name, func(t *testing.T) { + config := DefaultConfig() + testCase.mutate(&config) + _, err := Compose(valid, config, testOutput) + assertTapeInvalid(t, err) + }) + } +} + +// TestComposeCentralDirectivesOriginateOnlyFromConfig proves that no plugin +// text can introduce or alter a central presentation directive. +func TestComposeCentralDirectivesOriginateOnlyFromConfig(t *testing.T) { + hostile := []Directive{ + {Kind: KindType, Text: `x"` + "\n" + `Set FontSize 96` + "\n" + `Output "/work/evil.gif`, Count: 1, Line: 1}, + {Kind: KindType, Text: `Set Theme "Dracula"`, Count: 1, Line: 2}, + } + + got, err := Compose(hostile, DefaultConfig(), testOutput) + if err != nil { + // Rejecting hostile text outright is also a correct outcome. + assertTapeInvalid(t, err) + return + } + + lines := strings.Split(strings.TrimSuffix(string(got), "\n"), "\n") + for _, prefix := range []string{"Output ", "Set "} { + var count int + for _, line := range lines { + if strings.HasPrefix(line, prefix) { + count++ + } + } + switch prefix { + case "Output ": + if count != 1 { + t.Errorf("found %d Output lines, want exactly 1", count) + } + case "Set ": + if count != composedSetCount { + t.Errorf("found %d Set lines, want exactly %d", count, composedSetCount) + } + } + } + + if strings.Contains(string(got), "Set FontSize 96") { + t.Error("plugin text injected a central presentation directive") + } + if strings.Contains(string(got), `Output "/work/evil.gif"`) { + t.Error("plugin text injected an output destination") + } +} + +func TestDefaultConfigMatchesNormativePresentation(t *testing.T) { + config := DefaultConfig() + want := Config{ + Width: 960, + Height: 540, + FontFamily: "JetBrains Mono", + FontSize: 18, + Theme: "Catppuccin Mocha", + Framerate: 30, + TypingSpeed: 35 * time.Millisecond, + CursorBlink: false, + } + if config != want { + t.Errorf("DefaultConfig() = %+v, want %+v", config, want) + } + + bounds := limits.V1() + if config.Width != bounds.Width || config.Height != bounds.Height { + t.Errorf("config geometry %dx%d does not match V1 limits %dx%d", + config.Width, config.Height, bounds.Width, bounds.Height) + } +} + +// TestParseValidTapeFixtures proves the committed valid fixtures parse and +// then compose, so the fixtures track the real contract. +func TestParseValidTapeFixtures(t *testing.T) { + entries, err := filepath.Glob(filepath.Join("..", "..", "testdata", "valid", "tapes", "*.tape")) + if err != nil { + t.Fatalf("glob valid tape fixtures: %v", err) + } + if len(entries) == 0 { + t.Fatal("no valid tape fixtures found") + } + + for _, entry := range entries { + t.Run(filepath.Base(entry), func(t *testing.T) { + file, err := os.Open(entry) + if err != nil { + t.Fatalf("open fixture: %v", err) + } + defer file.Close() + + directives, err := Parse(file, limits.V1()) + if err != nil { + t.Fatalf("Parse returned an unexpected error: %v", err) + } + if len(directives) == 0 { + t.Fatal("fixture produced no directives") + } + if _, err := Compose(directives, DefaultConfig(), testOutput); err != nil { + t.Fatalf("Compose returned an unexpected error: %v", err) + } + }) + } +} + +// TestParseInvalidTapeFixtures proves each committed invalid fixture is +// rejected through the stable sanitized contract. +func TestParseInvalidTapeFixtures(t *testing.T) { + entries, err := filepath.Glob(filepath.Join("..", "..", "testdata", "invalid", "tapes", "*.tape")) + if err != nil { + t.Fatalf("glob invalid tape fixtures: %v", err) + } + if len(entries) == 0 { + t.Fatal("no invalid tape fixtures found") + } + + for _, entry := range entries { + t.Run(filepath.Base(entry), func(t *testing.T) { + file, err := os.Open(entry) + if err != nil { + t.Fatalf("open fixture: %v", err) + } + defer file.Close() + + _, err = Parse(file, limits.V1()) + assertTapeInvalid(t, err) + }) + } +} diff --git a/tools/readme-terminal-demo/testdata/invalid/tapes/output-directive.tape b/tools/readme-terminal-demo/testdata/invalid/tapes/output-directive.tape new file mode 100644 index 000000000..bc5d21596 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/tapes/output-directive.tape @@ -0,0 +1,3 @@ +Type "ll" +Output "/work/evil.gif" +Enter diff --git a/tools/readme-terminal-demo/testdata/invalid/tapes/set-directive.tape b/tools/readme-terminal-demo/testdata/invalid/tapes/set-directive.tape new file mode 100644 index 000000000..15ed4c64b --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/tapes/set-directive.tape @@ -0,0 +1,3 @@ +Type "ll" +Set FontSize 96 +Enter diff --git a/tools/readme-terminal-demo/testdata/invalid/tapes/source-indirection.tape b/tools/readme-terminal-demo/testdata/invalid/tapes/source-indirection.tape new file mode 100644 index 000000000..0022755fb --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/tapes/source-indirection.tape @@ -0,0 +1,3 @@ +Type "ll" +Source "other.tape" +Enter diff --git a/tools/readme-terminal-demo/testdata/invalid/tapes/timing-override.tape b/tools/readme-terminal-demo/testdata/invalid/tapes/timing-override.tape new file mode 100644 index 000000000..fe26521c1 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/invalid/tapes/timing-override.tape @@ -0,0 +1,2 @@ +Type@50ms "ll" +Enter diff --git a/tools/readme-terminal-demo/testdata/valid/tapes/behavior.tape b/tools/readme-terminal-demo/testdata/valid/tapes/behavior.tape new file mode 100644 index 000000000..d7da93395 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/valid/tapes/behavior.tape @@ -0,0 +1,9 @@ +# Behavior only. The renderer supplies every presentation control. +Type "ll --git" +Enter +Wait+Screen /\$ $/ +Sleep 1s +Type "ll --tree --level=2" +Enter +Wait+Line +Sleep 2s diff --git a/tools/readme-terminal-demo/testdata/valid/tapes/every-directive.tape b/tools/readme-terminal-demo/testdata/valid/tapes/every-directive.tape new file mode 100644 index 000000000..79bca1598 --- /dev/null +++ b/tools/readme-terminal-demo/testdata/valid/tapes/every-directive.tape @@ -0,0 +1,20 @@ +# Every interaction the v1 contract permits. +Type "printf 'ready\n'" +Enter +Tab +Space +Backspace 3 +Left +Right 2 +Up +Down 4 +PageUp +PageDown +ScrollUp 2 +ScrollDown +Ctrl+L +Sleep 500ms +Sleep 1s +Wait +Wait+Line +Wait+Screen /\$ $/